Updates: opt-in launch check, Check for Updates…, install after a normal quit, from the Gitea release; dmgs carry an Applications link (UPD-1, REL-1)
This commit is contained in:
28 files changed
+896
-16
No files matched your search
@@ -68,6 +68,12 @@ pub trait Closing: Host {
|
||||
});
|
||||
}
|
||||
|
||||
/// A close or quit stopped: no update installs on it.
|
||||
fn close_stopped(&mut self, cx: &mut Context<Self>) {
|
||||
ely_ui::updates::quit_stopped(cx);
|
||||
self.close_cancelled(cx);
|
||||
}
|
||||
|
||||
fn no_document(&self, cx: &mut Context<Self>) {
|
||||
let text = text(cx, "command.blocked.no_document", &[]);
|
||||
self.toast(text, Severity::Warning, cx);
|
||||
|
||||
@@ -87,7 +87,7 @@ pub trait Dialogs: Host {
|
||||
// Cancel stops the whole close.
|
||||
let cancelled = move |window: &mut Window, cx: &mut App| {
|
||||
owner.update(cx, |host, cx| {
|
||||
host.close_cancelled(cx);
|
||||
host.close_stopped(cx);
|
||||
host.dialog_done(window, cx)
|
||||
})
|
||||
};
|
||||
@@ -119,7 +119,7 @@ pub trait Dialogs: Host {
|
||||
let cancelled = move |window: &mut Window, cx: &mut App| {
|
||||
owner.update(cx, |host, cx| {
|
||||
if then.is_some() {
|
||||
host.close_cancelled(cx);
|
||||
host.close_stopped(cx);
|
||||
}
|
||||
host.dialog_done(window, cx)
|
||||
})
|
||||
@@ -135,7 +135,7 @@ pub trait Dialogs: Host {
|
||||
move |host, _, cx| {
|
||||
// Reloading ends the close that waited.
|
||||
if then.is_some() {
|
||||
host.close_cancelled(cx);
|
||||
host.close_stopped(cx);
|
||||
}
|
||||
host.reload(tab, cx)
|
||||
},
|
||||
|
||||
@@ -190,7 +190,7 @@ pub trait Saving: Host {
|
||||
}
|
||||
// No save: a close that waited on it stops.
|
||||
if then.is_some() {
|
||||
host.close_cancelled(cx);
|
||||
host.close_stopped(cx);
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -419,7 +419,7 @@ pub trait Saving: Host {
|
||||
failed => {
|
||||
self.report(id, failed, cx);
|
||||
if then.is_some() {
|
||||
self.close_cancelled(cx);
|
||||
self.close_stopped(cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
//! Puts a downloaded release in place of the running app.
|
||||
|
||||
use std::{
|
||||
io,
|
||||
path::{Path, PathBuf},
|
||||
process::{Command, Stdio},
|
||||
};
|
||||
|
||||
/// The `.app` running this process; none in dev builds.
|
||||
pub fn running_bundle() -> io::Result<Option<PathBuf>> {
|
||||
let exe = std::env::current_exe()?;
|
||||
Ok(bundle_of(&exe).map(Path::to_path_buf))
|
||||
}
|
||||
|
||||
fn bundle_of(exe: &Path) -> Option<&Path> {
|
||||
exe.ancestors().find(|dir| dir.extension().is_some_and(|ext| ext == "app"))
|
||||
}
|
||||
|
||||
/// Replaces `bundle` with the same-named app inside `dmg`.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn install(dmg: &Path, bundle: &Path) -> io::Result<()> {
|
||||
let name = bundle.file_name().ok_or_else(|| io::Error::other("the app has no name"))?;
|
||||
let parent = bundle.parent().ok_or_else(|| io::Error::other("the app has no folder"))?;
|
||||
let mount = tempfile::tempdir()?;
|
||||
run(Command::new("hdiutil").args(["attach", "-nobrowse", "-readonly", "-noautoopen", "-mountpoint"]).arg(mount.path()).arg(dmg))?;
|
||||
let copied = stage(&mount.path().join(name), parent, name);
|
||||
let detached = run(Command::new("hdiutil").args(["detach", "-force"]).arg(mount.path()));
|
||||
let staged = copied?;
|
||||
detached?;
|
||||
swap(&staged, bundle)
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub fn install(_dmg: &Path, _bundle: &Path) -> io::Result<()> {
|
||||
Err(io::Error::new(io::ErrorKind::Unsupported, "installing updates is not built for this platform"))
|
||||
}
|
||||
|
||||
/// Copies the new app beside the old, hidden.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn stage(from: &Path, parent: &Path, name: &std::ffi::OsStr) -> io::Result<PathBuf> {
|
||||
if !from.is_dir() {
|
||||
return Err(io::Error::new(io::ErrorKind::NotFound, format!("the download holds no {}", name.to_string_lossy())));
|
||||
}
|
||||
let staged = parent.join(format!(".{}.new", name.to_string_lossy()));
|
||||
if staged.exists() {
|
||||
std::fs::remove_dir_all(&staged)?;
|
||||
}
|
||||
run(Command::new("ditto").arg(from).arg(&staged))?;
|
||||
Ok(staged)
|
||||
}
|
||||
|
||||
/// Puts `staged` at `bundle`; on failure the old returns.
|
||||
fn swap(staged: &Path, bundle: &Path) -> io::Result<()> {
|
||||
let name = bundle.file_name().ok_or_else(|| io::Error::other("the app has no name"))?;
|
||||
let old = bundle.with_file_name(format!(".{}.old", name.to_string_lossy()));
|
||||
if old.exists() {
|
||||
std::fs::remove_dir_all(&old)?;
|
||||
}
|
||||
std::fs::rename(bundle, &old)?;
|
||||
if let Err(error) = std::fs::rename(staged, bundle) {
|
||||
std::fs::rename(&old, bundle)?;
|
||||
return Err(error);
|
||||
}
|
||||
log::info!("install: {} replaced", bundle.display());
|
||||
// Installed: an old copy left behind is only untidy.
|
||||
if let Err(error) = std::fs::remove_dir_all(&old) {
|
||||
log::warn!("install: the old copy at {} stays: {error}", old.display());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns once process `pid` has ended.
|
||||
#[cfg(unix)]
|
||||
pub fn wait_for_exit(pid: u32) {
|
||||
while unsafe { libc::kill(pid as libc::pid_t, 0) } == 0 {
|
||||
std::thread::sleep(std::time::Duration::from_millis(200));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
pub fn wait_for_exit(_pid: u32) {}
|
||||
|
||||
/// Opens `bundle` as a fresh launch.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn reopen(bundle: &Path) -> io::Result<()> {
|
||||
run(Command::new("open").arg(bundle))
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub fn reopen(_bundle: &Path) -> io::Result<()> {
|
||||
Err(io::Error::new(io::ErrorKind::Unsupported, "reopening after an update is not built for this platform"))
|
||||
}
|
||||
|
||||
/// Runs `command`; a failure carries what it said.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn run(command: &mut Command) -> io::Result<()> {
|
||||
let out = command.stdin(Stdio::null()).output()?;
|
||||
if out.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
let said = String::from_utf8_lossy(&out.stderr);
|
||||
Err(io::Error::other(format!("{:?} failed ({}): {}", command.get_program(), out.status, said.trim())))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_bundle_is_the_nearest_app_folder() {
|
||||
let exe = Path::new("/Applications/ElyDoc.app/Contents/MacOS/ely-doc");
|
||||
assert_eq!(bundle_of(exe), Some(Path::new("/Applications/ElyDoc.app")));
|
||||
assert_eq!(bundle_of(Path::new("/repo/target/debug/ely-doc")), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_swap_replaces_the_app_and_leaves_no_copy() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (bundle, staged) = (dir.path().join("ElyDoc.app"), dir.path().join(".ElyDoc.app.new"));
|
||||
std::fs::create_dir_all(bundle.join("old")).expect("the old app");
|
||||
std::fs::create_dir_all(staged.join("new")).expect("the new app");
|
||||
swap(&staged, &bundle).expect("swaps");
|
||||
assert!(bundle.join("new").exists() && !bundle.join("old").exists());
|
||||
assert_eq!(std::fs::read_dir(dir.path()).expect("lists").count(), 1, "nothing else is left");
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn an_old_copy_that_will_not_go_leaves_the_install_done() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (bundle, staged) = (dir.path().join("ElyDoc.app"), dir.path().join(".ElyDoc.app.new"));
|
||||
std::fs::create_dir_all(&bundle).expect("the old app");
|
||||
std::fs::write(bundle.join("version"), "old").expect("marks it");
|
||||
std::fs::create_dir_all(&staged).expect("the new app");
|
||||
std::fs::write(staged.join("version"), "new").expect("marks it");
|
||||
run(Command::new("chflags").arg("uchg").arg(bundle.join("version"))).expect("locks a file");
|
||||
swap(&staged, &bundle).expect("installed although the old copy stays");
|
||||
assert_eq!(std::fs::read_to_string(bundle.join("version")).expect("reads"), "new");
|
||||
let old = dir.path().join(".ElyDoc.app.old");
|
||||
run(Command::new("chflags").arg("nouchg").arg(old.join("version"))).expect("unlocks");
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn a_dmg_installs_over_the_app() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let source = dir.path().join("src");
|
||||
std::fs::create_dir_all(source.join("ElyDoc.app/Contents")).expect("the new app");
|
||||
std::fs::write(source.join("ElyDoc.app/Contents/version"), "new").expect("marks it");
|
||||
let dmg = dir.path().join("ElyDoc.dmg");
|
||||
run(Command::new("hdiutil").args(["create", "-quiet", "-fs", "HFS+", "-srcfolder"]).arg(&source).arg(&dmg)).expect("a dmg");
|
||||
let bundle = dir.path().join("Applications/ElyDoc.app");
|
||||
std::fs::create_dir_all(bundle.join("Contents")).expect("the old app");
|
||||
std::fs::write(bundle.join("Contents/version"), "old").expect("marks it");
|
||||
install(&dmg, &bundle).expect("installs");
|
||||
assert_eq!(std::fs::read_to_string(bundle.join("Contents/version")).expect("reads"), "new");
|
||||
let empty = dir.path().join("Applications/Other.app");
|
||||
assert!(install(&dmg, &empty).is_err(), "a dmg without that app is refused");
|
||||
}
|
||||
}
|
||||
@@ -7,12 +7,14 @@ mod dirs;
|
||||
pub mod env;
|
||||
pub mod git;
|
||||
pub mod graphics;
|
||||
pub mod install;
|
||||
pub mod instance;
|
||||
pub mod locale;
|
||||
pub mod lock;
|
||||
pub mod logging;
|
||||
pub mod media;
|
||||
pub mod print;
|
||||
pub mod release;
|
||||
pub mod settings;
|
||||
pub mod startup;
|
||||
pub mod storage;
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
//! The newest release's files, as the release server lists them.
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Latest {
|
||||
tag_name: String,
|
||||
assets: Vec<Asset>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Asset {
|
||||
name: String,
|
||||
browser_download_url: String,
|
||||
}
|
||||
|
||||
/// Where the latest release at `api` keeps the file `name`.
|
||||
pub fn asset_url(api: &str, name: &str) -> Result<String> {
|
||||
let text = crate::update::fetch(&crate::update::manifest_agent(), api)?
|
||||
.body_mut()
|
||||
.with_config()
|
||||
.limit(1 << 20)
|
||||
.read_to_string()
|
||||
.context("cannot read the latest release")?;
|
||||
asset_in(&text, name)
|
||||
}
|
||||
|
||||
fn asset_in(text: &str, name: &str) -> Result<String> {
|
||||
let latest: Latest = serde_json::from_str(text).context("the latest release is not valid")?;
|
||||
match latest.assets.into_iter().find(|asset| asset.name == name) {
|
||||
Some(asset) => Ok(asset.browser_download_url),
|
||||
None => bail!("release {} has no {name}", latest.tag_name),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn the_named_file_is_found_or_said_missing() {
|
||||
let text = r#"{"tag_name":"v0.1.2","assets":[{"name":"ely-doc.json","browser_download_url":"https://h/d.json"}]}"#;
|
||||
assert_eq!(super::asset_in(text, "ely-doc.json").unwrap(), "https://h/d.json");
|
||||
let missing = super::asset_in(text, "ely-tree.json").unwrap_err().to_string();
|
||||
assert_eq!(missing, "release v0.1.2 has no ely-tree.json");
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ use serde_json::{Map, Value};
|
||||
use crate::{AppId, lock::FileLock};
|
||||
|
||||
mod shared;
|
||||
pub use shared::{APPEARANCE, AUTHOR, Appearance, LANGUAGE, Language, MOTION, Motion, OMARCHY_SYNC, Shared, word};
|
||||
pub use shared::{APPEARANCE, AUTHOR, Appearance, CHECK_UPDATES, LANGUAGE, Language, MOTION, Motion, OMARCHY_SYNC, Shared, word};
|
||||
|
||||
/// The layout this build reads and writes.
|
||||
pub const VERSION: u64 = 1;
|
||||
|
||||
@@ -38,6 +38,7 @@ pub const LANGUAGE: &str = "language";
|
||||
pub const MOTION: &str = "motion";
|
||||
pub const OMARCHY_SYNC: &str = "omarchy_sync";
|
||||
pub const AUTHOR: &str = "author";
|
||||
pub const CHECK_UPDATES: &str = "check_updates";
|
||||
|
||||
/// The shared settings; absent fields take their defaults.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
@@ -48,6 +49,8 @@ pub struct Shared {
|
||||
pub omarchy_sync: bool,
|
||||
/// Signs comments and changes; empty means the account name.
|
||||
pub author: String,
|
||||
/// Asks for a newer release at launch; off unless chosen.
|
||||
pub check_updates: bool,
|
||||
}
|
||||
|
||||
impl Default for Shared {
|
||||
@@ -58,6 +61,7 @@ impl Default for Shared {
|
||||
motion: Motion::System,
|
||||
omarchy_sync: true,
|
||||
author: String::new(),
|
||||
check_updates: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -92,6 +96,11 @@ impl Shared {
|
||||
appearance: pick(fields, APPEARANCE, &Appearance::ALL)?.unwrap_or(defaults.appearance),
|
||||
language: pick(fields, LANGUAGE, &Language::ALL)?.unwrap_or(defaults.language),
|
||||
motion: pick(fields, MOTION, &Motion::ALL)?.unwrap_or(defaults.motion),
|
||||
check_updates: match fields.get(CHECK_UPDATES) {
|
||||
None => defaults.check_updates,
|
||||
Some(Value::Bool(on)) => *on,
|
||||
Some(other) => return Err(wrong(CHECK_UPDATES, other)),
|
||||
},
|
||||
omarchy_sync: match fields.get(OMARCHY_SYNC) {
|
||||
None => defaults.omarchy_sync,
|
||||
Some(Value::Bool(on)) => *on,
|
||||
|
||||
@@ -63,14 +63,15 @@ fn shared_fields_type_strictly_and_default_when_absent() {
|
||||
let fields = |text: &str| serde_json::from_str::<serde_json::Map<_, _>>(text).unwrap();
|
||||
assert_eq!(Shared::from_fields(&fields("{}")).unwrap(), Shared::default());
|
||||
let read = Shared::from_fields(&fields(
|
||||
r#"{"appearance":"dark","language":"zh-CN","motion":"reduce","omarchy_sync":false,"author":" Ana "}"#,
|
||||
r#"{"appearance":"dark","language":"zh-CN","motion":"reduce","omarchy_sync":false,"author":" Ana ","check_updates":true}"#,
|
||||
))
|
||||
.unwrap();
|
||||
assert!(read.check_updates && !Shared::default().check_updates, "asked for, never by default");
|
||||
assert_eq!(
|
||||
(read.appearance, read.language, read.motion, read.omarchy_sync, read.author.as_str()),
|
||||
(Appearance::Dark, Language::Chinese, Motion::Reduced, false, "Ana")
|
||||
);
|
||||
for bad in [r#"{"appearance":"blue"}"#, r#"{"omarchy_sync":"yes"}"#, r#"{"author":3}"#] {
|
||||
for bad in [r#"{"appearance":"blue"}"#, r#"{"omarchy_sync":"yes"}"#, r#"{"author":3}"#, r#"{"check_updates":1}"#] {
|
||||
assert!(Shared::from_fields(&fields(bad)).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -305,7 +305,7 @@ fn secure_host(url: &str) -> Result<String> {
|
||||
}
|
||||
|
||||
/// GETs `url`, following redirects only to places `secure_host` accepts.
|
||||
fn fetch(agent: &Agent, url: &str) -> Result<Response<Body>> {
|
||||
pub(crate) fn fetch(agent: &Agent, url: &str) -> Result<Response<Body>> {
|
||||
let mut url = url.to_string();
|
||||
for _ in 0..=MAX_REDIRECTS {
|
||||
secure_host(&url)?;
|
||||
@@ -335,7 +335,7 @@ fn fetch(agent: &Agent, url: &str) -> Result<Response<Body>> {
|
||||
bail!("too many redirects")
|
||||
}
|
||||
|
||||
fn manifest_agent() -> Agent {
|
||||
pub(crate) fn manifest_agent() -> Agent {
|
||||
Agent::config_builder()
|
||||
.timeout_global(Some(MANIFEST_TIMEOUT))
|
||||
.max_redirects(0)
|
||||
|
||||
@@ -13,6 +13,7 @@ ely-platform.workspace = true
|
||||
gpui.workspace = true
|
||||
directories.workspace = true
|
||||
futures.workspace = true
|
||||
semver.workspace = true
|
||||
gpui_platform.workspace = true
|
||||
log.workspace = true
|
||||
notify.workspace = true
|
||||
|
||||
@@ -18,6 +18,7 @@ actions!(
|
||||
CloseTab,
|
||||
ToggleCommandPalette,
|
||||
OpenSettings,
|
||||
CheckForUpdates,
|
||||
Quit
|
||||
]
|
||||
);
|
||||
@@ -43,7 +44,7 @@ pub fn bind_quit(cx: &mut App) {
|
||||
}
|
||||
|
||||
/// Quits through the main window's route, if one is set.
|
||||
fn quit(cx: &mut App) {
|
||||
pub(crate) fn quit(cx: &mut App) {
|
||||
match cx.try_global::<QuitRoute>().map(|route| Rc::clone(&route.0)) {
|
||||
Some(route) => route(cx),
|
||||
None => cx.quit(),
|
||||
@@ -171,6 +172,10 @@ pub fn menus(
|
||||
first.push(MenuItem::separator());
|
||||
}
|
||||
first.push(MenuItem::action(i18n::text(cx, "menu.app.settings", &[]), OpenSettings));
|
||||
// Only an installed app can update itself.
|
||||
if crate::updates::updater(cx).is_some() {
|
||||
first.push(MenuItem::action(i18n::text(cx, "menu.app.updates", &[]), CheckForUpdates));
|
||||
}
|
||||
first.push(MenuItem::separator());
|
||||
first.push(MenuItem::action(i18n::text(cx, "menu.app.quit", &[("app", app.name())]), Quit));
|
||||
let mut menus = vec![
|
||||
|
||||
@@ -24,6 +24,9 @@ use gpui::{App, SharedString};
|
||||
|
||||
const EN: &[(&str, &str)] = &[
|
||||
("menu.app.quit", "Quit {app}"),
|
||||
("updates.failed", "The update did not finish: {error}"),
|
||||
("updates.latest", "{app} {version} is the latest version."),
|
||||
("menu.app.updates", "Check for Updates…"),
|
||||
("menu.app.switch", "Open Ely App"),
|
||||
("toast.open", "Open"),
|
||||
("toast.open_failed", "The file did not open: {error}"),
|
||||
@@ -76,6 +79,9 @@ const EN: &[(&str, &str)] = &[
|
||||
|
||||
const ZH_CN: &[(&str, &str)] = &[
|
||||
("menu.app.quit", "退出 {app}"),
|
||||
("updates.failed", "更新没有完成:{error}"),
|
||||
("updates.latest", "{app} {version} 已是最新版本。"),
|
||||
("menu.app.updates", "检查更新…"),
|
||||
("menu.app.switch", "打开 Ely 应用"),
|
||||
("toast.open", "打开"),
|
||||
("toast.open_failed", "文件没能打开:{error}"),
|
||||
|
||||
@@ -9,6 +9,11 @@ pub(super) const EN: &[(&str, &str)] = &[
|
||||
("settings.appearance.light", "Light"),
|
||||
("settings.appearance.dark", "Dark"),
|
||||
("settings.omarchy", "Follow Omarchy"),
|
||||
("settings.updates", "Check for updates at launch"),
|
||||
(
|
||||
"settings.updates.hint",
|
||||
"Off, Ely asks its release server for a newer version only when you choose Check for Updates.",
|
||||
),
|
||||
("settings.language", "Language"),
|
||||
("settings.language.system", "System"),
|
||||
("settings.language.next", "Applies the next time each app starts"),
|
||||
@@ -64,6 +69,11 @@ pub(super) const ZH_CN: &[(&str, &str)] = &[
|
||||
("settings.appearance.light", "浅色"),
|
||||
("settings.appearance.dark", "深色"),
|
||||
("settings.omarchy", "跟随 Omarchy"),
|
||||
("settings.updates", "启动时检查更新"),
|
||||
(
|
||||
"settings.updates.hint",
|
||||
"关闭时,只在你选择“检查更新”时才向发布服务器询问新版本。",
|
||||
),
|
||||
("settings.language", "语言"),
|
||||
("settings.language.system", "跟随系统"),
|
||||
("settings.language.next", "各应用下次启动时生效"),
|
||||
|
||||
@@ -6,6 +6,7 @@ pub mod settings;
|
||||
mod shell;
|
||||
pub mod tasks;
|
||||
mod theme;
|
||||
pub mod updates;
|
||||
|
||||
pub use opening::{launch_files, on_open};
|
||||
pub use run::run;
|
||||
|
||||
@@ -15,6 +15,10 @@ pub fn run<V: Render>(
|
||||
app: AppId,
|
||||
root: impl FnOnce(AppId, &mut Window, &mut App) -> Entity<V> + 'static,
|
||||
) {
|
||||
// As the installer after an update, nothing else runs.
|
||||
if let Some(code) = crate::updates::installer(app) {
|
||||
std::process::exit(code);
|
||||
}
|
||||
// Before the log: a second copy must not trim it.
|
||||
if let Err(error) = ely_platform::instance::hold(app) {
|
||||
startup::fail(app, &format!("{error}"));
|
||||
@@ -77,6 +81,8 @@ fn open<V: Render>(
|
||||
}
|
||||
theme::install(cx);
|
||||
crate::i18n::install(cx);
|
||||
crate::updates::install(app, cx);
|
||||
cx.on_action(|_: &crate::commands::CheckForUpdates, cx| crate::updates::check_now(cx));
|
||||
crate::commands::bind_keys(cx);
|
||||
crate::commands::bind_quit(cx);
|
||||
cx.on_action(move |_: &crate::commands::OpenSettings, cx| crate::settings::open(app, cx));
|
||||
|
||||
@@ -9,7 +9,7 @@ use ely_gpui_component::{
|
||||
};
|
||||
use ely_platform::{
|
||||
AppId,
|
||||
settings::{APPEARANCE, AUTHOR, Appearance, LANGUAGE, Language, MOTION, Motion, OMARCHY_SYNC, Store, word},
|
||||
settings::{APPEARANCE, AUTHOR, Appearance, CHECK_UPDATES, LANGUAGE, Language, MOTION, Motion, OMARCHY_SYNC, Store, word},
|
||||
};
|
||||
use gpui::{
|
||||
AppContext as _, Context, Entity, FocusHandle, Global, IntoElement, ParentElement, Render, SharedString, Styled,
|
||||
@@ -222,7 +222,7 @@ impl SettingsView {
|
||||
Motion::Reduced => text(cx, "settings.motion.reduce", &[]),
|
||||
Motion::Full => text(cx, "settings.motion.full", &[]),
|
||||
}, MOTION, cx);
|
||||
let mut section = SettingsSection::new(text(cx, "settings.general", &[]))
|
||||
let section = SettingsSection::new(text(cx, "settings.general", &[]))
|
||||
.row(SettingsRow::new(text(cx, "settings.appearance", &[])).control(theme))
|
||||
.row(
|
||||
SettingsRow::new(text(cx, "settings.language", &[]))
|
||||
@@ -230,6 +230,14 @@ impl SettingsView {
|
||||
.control(language),
|
||||
)
|
||||
.row(SettingsRow::new(text(cx, "settings.motion", &[])).control(motion));
|
||||
let updates = Switch::new("settings-updates", shared.check_updates)
|
||||
.name(text(cx, "settings.updates", &[]))
|
||||
.on_change(|on, _, cx| set(CHECK_UPDATES, Value::Bool(on), cx));
|
||||
let mut section = section.row(
|
||||
SettingsRow::new(text(cx, "settings.updates", &[]))
|
||||
.description(text(cx, "settings.updates.hint", &[]))
|
||||
.control(updates),
|
||||
);
|
||||
// Only where Omarchy is installed.
|
||||
if ely_platform::omarchy_current_dir().is_some() {
|
||||
let follow = Switch::new("settings-omarchy", shared.omarchy_sync)
|
||||
|
||||
@@ -124,6 +124,7 @@ impl RenderOnce for Shell {
|
||||
.flex()
|
||||
.flex_col()
|
||||
.child(TitleBar::new("titlebar").title(self.title))
|
||||
.children(crate::updates::updater(cx))
|
||||
.children(menu_bar(cx))
|
||||
.children(self.tabs);
|
||||
// Panels give way so the content keeps its room.
|
||||
|
||||
@@ -102,6 +102,33 @@ fn build(
|
||||
if !document {
|
||||
window.focus(&focus, cx);
|
||||
}
|
||||
if let Some(updates) = crate::updates::updater(cx) {
|
||||
cx.subscribe(
|
||||
&updates,
|
||||
|root: &mut Root, _, news: &crate::updates::UpdateNews, cx| match news {
|
||||
crate::updates::UpdateNews::Failed(error) => {
|
||||
let text = crate::i18n::text(cx, "updates.failed", &[("error", error)]);
|
||||
root.toast(text, Severity::Danger, cx);
|
||||
}
|
||||
crate::updates::UpdateNews::Latest => {
|
||||
let args = [
|
||||
("app", root.app.name()),
|
||||
("version", env!("CARGO_PKG_VERSION")),
|
||||
];
|
||||
let text = crate::i18n::text(cx, "updates.latest", &args);
|
||||
root.toast(text, Severity::Success, cx);
|
||||
}
|
||||
},
|
||||
)
|
||||
.detach();
|
||||
// A failed install from the last quit says why.
|
||||
if let Some(note) = updates.update(cx, |updates, _| updates.note.take()) {
|
||||
let text = crate::i18n::text(cx, "updates.failed", &[("error", ¬e)]);
|
||||
cx.defer_in(window, move |root: &mut Root, _, cx| {
|
||||
root.toast(text, Severity::Danger, cx)
|
||||
});
|
||||
}
|
||||
}
|
||||
Root {
|
||||
app,
|
||||
focus,
|
||||
|
||||
@@ -0,0 +1,428 @@
|
||||
//! A newer release: offered, downloaded, installed after quitting.
|
||||
|
||||
use std::{
|
||||
io,
|
||||
path::{Path, PathBuf},
|
||||
process::{Command, Stdio},
|
||||
sync::atomic::AtomicBool,
|
||||
};
|
||||
|
||||
use ely_gpui_component::shell::{UpdateBanner, UpdateState};
|
||||
use ely_platform::{
|
||||
AppId,
|
||||
update::{self, Check, Release},
|
||||
};
|
||||
use futures::{StreamExt as _, channel::mpsc};
|
||||
use gpui::{
|
||||
App, AppContext as _, Context, Entity, EventEmitter, Global, IntoElement, Render, Window, div,
|
||||
};
|
||||
|
||||
/// The release server's newest release, listed by its API.
|
||||
const LATEST: &str = "https://git.zacharyzhang.com/api/v1/repos/ZacharyZhang-NY/ElyOffice/releases/latest";
|
||||
/// The argument that runs this binary as the installer.
|
||||
const INSTALL: &str = "--ely-install-update";
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
enum Phase {
|
||||
Offered,
|
||||
Fetching(f32),
|
||||
Fetched(PathBuf),
|
||||
}
|
||||
|
||||
pub struct Updates {
|
||||
app: AppId,
|
||||
bundle: PathBuf,
|
||||
offer: Option<(Release, Phase)>,
|
||||
dismissed: bool,
|
||||
/// The download to install once the app has quit.
|
||||
pending: Option<PathBuf>,
|
||||
/// Why the last install failed, for the window to say.
|
||||
pub note: Option<String>,
|
||||
}
|
||||
|
||||
/// What an update says to the window.
|
||||
pub enum UpdateNews {
|
||||
Failed(String),
|
||||
/// A check the user asked for found nothing newer.
|
||||
Latest,
|
||||
}
|
||||
|
||||
impl EventEmitter<UpdateNews> for Updates {}
|
||||
|
||||
struct Updater(Entity<Updates>);
|
||||
|
||||
impl Global for Updater {}
|
||||
|
||||
/// The app's updater, if it runs from an installed bundle.
|
||||
pub fn updater(cx: &App) -> Option<Entity<Updates>> {
|
||||
cx.try_global::<Updater>().map(|updater| updater.0.clone())
|
||||
}
|
||||
|
||||
/// Checks now, as the user asked from the menu.
|
||||
pub fn check_now(cx: &mut App) {
|
||||
if let Some(updates) = updater(cx) {
|
||||
updates.update(cx, |updates, cx| updates.check(true, cx));
|
||||
}
|
||||
}
|
||||
|
||||
/// The quit an update asked for stopped: install nothing.
|
||||
pub fn quit_stopped(cx: &mut App) {
|
||||
if let Some(updates) = updater(cx) {
|
||||
updates.update(cx, |updates, _| {
|
||||
if updates.pending.take().is_some() {
|
||||
log::info!("update: the quit stopped; nothing installs");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Run as the installer: waits, installs, reopens; the exit code.
|
||||
pub fn installer(app: AppId) -> Option<i32> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let [_, flag, dmg, bundle, pid] = args.as_slice() else {
|
||||
return None;
|
||||
};
|
||||
if flag != INSTALL {
|
||||
return None;
|
||||
}
|
||||
let Ok(pid) = pid.parse() else {
|
||||
eprintln!("update: {pid} is not a process number");
|
||||
return Some(2);
|
||||
};
|
||||
ely_platform::install::wait_for_exit(pid);
|
||||
let installed = ely_platform::install::install(Path::new(dmg), Path::new(bundle));
|
||||
if let Err(error) = &installed {
|
||||
// To the app's log; the reopened app shows it.
|
||||
eprintln!("update: the install failed: {error}");
|
||||
let note = ely_platform::data_dir(app)
|
||||
.map_err(io::Error::other)
|
||||
.and_then(|dir| {
|
||||
std::fs::create_dir_all(dir.join("updates"))?;
|
||||
std::fs::write(dir.join("updates/failed.txt"), error.to_string())
|
||||
});
|
||||
if let Err(lost) = note {
|
||||
eprintln!("update: the failure note was not written: {lost}");
|
||||
}
|
||||
}
|
||||
// Last: the note is written before the app reads it.
|
||||
if let Err(error) = ely_platform::install::reopen(Path::new(bundle)) {
|
||||
eprintln!("update: the app did not reopen: {error}");
|
||||
return Some(1);
|
||||
}
|
||||
Some(i32::from(installed.is_err()))
|
||||
}
|
||||
|
||||
/// An installed app's updater; checks at launch if chosen.
|
||||
pub(crate) fn install(app: AppId, cx: &mut App) {
|
||||
let bundle = match ely_platform::install::running_bundle() {
|
||||
Ok(Some(bundle)) => bundle,
|
||||
Ok(None) => return log::info!("update: a dev build; no check"),
|
||||
Err(error) => return log::warn!("update: the running app is not found: {error}"),
|
||||
};
|
||||
match update::install_policy() {
|
||||
Ok(update::InstallPolicy::SelfManaged) => {}
|
||||
Ok(update::InstallPolicy::PackageManager) => {
|
||||
return log::info!("update: the package manager updates this app");
|
||||
}
|
||||
Err(error) => return log::warn!("update: {error:#}"),
|
||||
}
|
||||
let note = ely_platform::data_dir(app)
|
||||
.ok()
|
||||
.map(|dir| dir.join("updates/failed.txt"));
|
||||
let note = note.and_then(|path| {
|
||||
std::fs::read_to_string(&path)
|
||||
.ok()
|
||||
.inspect(|_| drop(std::fs::remove_file(&path)))
|
||||
});
|
||||
let updates = cx.new(|cx| {
|
||||
cx.on_app_quit(|this: &mut Updates, _| {
|
||||
this.start_installer();
|
||||
async {}
|
||||
})
|
||||
.detach();
|
||||
Updates {
|
||||
app,
|
||||
bundle,
|
||||
offer: None,
|
||||
dismissed: false,
|
||||
pending: None,
|
||||
note,
|
||||
}
|
||||
});
|
||||
cx.set_global(Updater(updates.clone()));
|
||||
// Off by default: no request unless the user chose it.
|
||||
if crate::settings::current(cx).check_updates {
|
||||
updates.update(cx, |updates, cx| updates.check(false, cx));
|
||||
} else {
|
||||
log::info!("update: no check at launch; it is off in settings");
|
||||
}
|
||||
}
|
||||
|
||||
/// The app's manifest in the newest release, or the test's.
|
||||
fn manifest(app: AppId) -> anyhow::Result<String> {
|
||||
match std::env::var("ELY_UPDATE_MANIFEST") {
|
||||
Ok(url) => Ok(url),
|
||||
Err(_) => ely_platform::release::asset_url(LATEST, &format!("{}.json", app.slug())),
|
||||
}
|
||||
}
|
||||
|
||||
impl Updates {
|
||||
/// Asks for a newer release; `asked` reports every outcome.
|
||||
fn check(&mut self, asked: bool, cx: &mut Context<Self>) {
|
||||
// A download under way or done stays the offer.
|
||||
if matches!(
|
||||
self.offer,
|
||||
Some((_, Phase::Fetching(_) | Phase::Fetched(_)))
|
||||
) {
|
||||
log::info!("update: a release is already on its way");
|
||||
if asked {
|
||||
self.dismissed = false;
|
||||
cx.notify();
|
||||
}
|
||||
return;
|
||||
}
|
||||
let app = self.app;
|
||||
let current =
|
||||
semver::Version::parse(env!("CARGO_PKG_VERSION")).expect("the build's version");
|
||||
// The lookup is a request too: off the window's thread.
|
||||
let checked = cx.background_spawn(async move { manifest(app).and_then(|url| update::check(¤t, &url)) });
|
||||
cx.spawn(async move |this, cx| {
|
||||
let checked = checked.await;
|
||||
this.update(cx, |this, cx| match checked {
|
||||
Ok(Check::Available(_))
|
||||
if matches!(
|
||||
this.offer,
|
||||
Some((_, Phase::Fetching(_) | Phase::Fetched(_)))
|
||||
) => {}
|
||||
Ok(Check::Available(release)) => {
|
||||
(this.offer, this.dismissed) = (Some((release, Phase::Offered)), false);
|
||||
cx.notify();
|
||||
}
|
||||
Ok(Check::Current) if asked => cx.emit(UpdateNews::Latest),
|
||||
// At launch a failure only logs; offline is normal.
|
||||
Err(error) if asked => cx.emit(UpdateNews::Failed(format!("{error:#}"))),
|
||||
Err(error) => log::warn!("update: the launch check failed: {error:#}"),
|
||||
Ok(Check::Current) => log::info!("update: nothing newer at launch"),
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn later(&mut self, cx: &mut Context<Self>) {
|
||||
self.dismissed = true;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn download(&mut self, cx: &mut Context<Self>) {
|
||||
let Some((release, phase)) = self
|
||||
.offer
|
||||
.as_mut()
|
||||
.filter(|(_, phase)| *phase == Phase::Offered)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
*phase = Phase::Fetching(0.0);
|
||||
cx.notify();
|
||||
let (release, app) = (release.clone(), self.app);
|
||||
let (sender, mut progress) = mpsc::unbounded();
|
||||
let fetching = release.clone();
|
||||
let fetched = cx.background_spawn(async move {
|
||||
let dir = ely_platform::data_dir(app)
|
||||
.map_err(|error| format!("{error:#}"))?
|
||||
.join("updates");
|
||||
if dir.exists() {
|
||||
std::fs::remove_dir_all(&dir).map_err(|error| error.to_string())?;
|
||||
}
|
||||
std::fs::create_dir_all(&dir).map_err(|error| error.to_string())?;
|
||||
let cancel = AtomicBool::new(false);
|
||||
let mut tell = |done: u64, total: Option<u64>| {
|
||||
let part = total
|
||||
.filter(|total| *total > 0)
|
||||
.map_or(0.0, |total| done as f32 / total as f32);
|
||||
sender.unbounded_send(part).ok();
|
||||
};
|
||||
let file = update::download(&fetching, &dir, &cancel, update::IDLE_TIMEOUT, &mut tell)
|
||||
.map_err(|error| format!("{error:#}"))?
|
||||
.ok_or("the download stopped")?;
|
||||
update::verify_file(&file, &fetching.sha256).map_err(|error| format!("{error:#}"))?;
|
||||
Ok::<PathBuf, String>(file)
|
||||
});
|
||||
cx.spawn(async move |this, cx| {
|
||||
while let Some(part) = progress.next().await {
|
||||
this.update(cx, |this, cx| this.fetching(part, cx)).ok();
|
||||
}
|
||||
let fetched = fetched.await;
|
||||
this.update(cx, |this, cx| this.fetched(&release, fetched, cx))
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn fetching(&mut self, part: f32, cx: &mut Context<Self>) {
|
||||
if let Some((_, phase @ Phase::Fetching(_))) = self.offer.as_mut() {
|
||||
*phase = Phase::Fetching(part);
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
/// A finished download counts only for the release it fetched.
|
||||
fn fetched(
|
||||
&mut self,
|
||||
release: &Release,
|
||||
fetched: Result<PathBuf, String>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some((offered, phase)) = self
|
||||
.offer
|
||||
.as_mut()
|
||||
.filter(|(offered, _)| offered == release)
|
||||
else {
|
||||
return log::warn!("update: a download for another release was dropped");
|
||||
};
|
||||
match fetched {
|
||||
Ok(file) => *phase = Phase::Fetched(file),
|
||||
Err(error) => {
|
||||
*phase = Phase::Offered;
|
||||
cx.emit(UpdateNews::Failed(error));
|
||||
}
|
||||
}
|
||||
log::info!("update: {} is {:?}", offered.version, phase);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Quits as usual: unsaved work is asked about first.
|
||||
fn restart(&mut self, cx: &mut Context<Self>) {
|
||||
let Some((_, Phase::Fetched(file))) = self.offer.as_ref() else {
|
||||
return;
|
||||
};
|
||||
self.pending = Some(file.clone());
|
||||
crate::commands::quit(cx);
|
||||
}
|
||||
|
||||
/// At the real quit: the installer waits for this process.
|
||||
fn start_installer(&mut self) {
|
||||
let Some(file) = self.pending.take() else {
|
||||
return;
|
||||
};
|
||||
let started = std::env::current_exe().and_then(|exe| {
|
||||
let mut command = Command::new(exe);
|
||||
command
|
||||
.arg(INSTALL)
|
||||
.arg(&file)
|
||||
.arg(&self.bundle)
|
||||
.arg(std::process::id().to_string());
|
||||
// Its words go to the app's log.
|
||||
let log = ely_platform::logging::path()
|
||||
.map(|path| std::fs::OpenOptions::new().append(true).open(path));
|
||||
let said = match log {
|
||||
Some(file) => Stdio::from(file?),
|
||||
None => Stdio::null(),
|
||||
};
|
||||
command
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(said);
|
||||
#[cfg(unix)]
|
||||
std::os::unix::process::CommandExt::process_group(&mut command, 0);
|
||||
command.spawn()
|
||||
});
|
||||
match started {
|
||||
Ok(_) => log::info!("update: installs after this quit"),
|
||||
Err(error) => log::error!("update: the installer did not start: {error}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Updates {
|
||||
/// The bar under the title while a release is offered.
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let this = cx.entity();
|
||||
let Some((release, phase)) = self.offer.as_ref().filter(|_| !self.dismissed) else {
|
||||
return div().into_any_element();
|
||||
};
|
||||
let state = match phase {
|
||||
Phase::Offered => UpdateState::Available,
|
||||
Phase::Fetching(done) => UpdateState::Downloading(*done),
|
||||
Phase::Fetched(_) => UpdateState::Ready,
|
||||
};
|
||||
let (download, restart, later) = (this.clone(), this.clone(), this);
|
||||
let banner = UpdateBanner::new("update-banner", release.version.to_string(), state)
|
||||
.on_download(move |_, cx| download.update(cx, |this, cx| this.download(cx)))
|
||||
.on_later(move |_, cx| later.update(cx, |this, cx| this.later(cx)));
|
||||
match phase {
|
||||
Phase::Fetched(_) => banner
|
||||
.on_restart(move |_, cx| restart.update(cx, |this, cx| this.restart(cx)))
|
||||
.into_any_element(),
|
||||
_ => banner.into_any_element(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn release(version: &str, url: &str) -> Release {
|
||||
Release {
|
||||
version: semver::Version::parse(version).expect("a version"),
|
||||
url: url.into(),
|
||||
sha256: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_check_brings_back_a_dismissed_download(cx: &mut gpui::TestAppContext) {
|
||||
let updates = cx.new(|_| Updates {
|
||||
app: AppId::Tree,
|
||||
bundle: PathBuf::from("/Applications/ElyTree.app"),
|
||||
offer: Some((
|
||||
release("9.0.0", "https://b"),
|
||||
Phase::Fetched(PathBuf::from("B.dmg")),
|
||||
)),
|
||||
dismissed: true,
|
||||
pending: None,
|
||||
note: None,
|
||||
});
|
||||
updates.update(cx, |updates, cx| updates.check(true, cx));
|
||||
updates.read_with(cx, |updates, _| {
|
||||
assert!(!updates.dismissed, "the bar shows again");
|
||||
assert_eq!(
|
||||
updates.offer.as_ref().map(|(_, phase)| phase.clone()),
|
||||
Some(Phase::Fetched(PathBuf::from("B.dmg")))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_download_for_an_older_offer_is_dropped(cx: &mut gpui::TestAppContext) {
|
||||
let updates = cx.new(|_| Updates {
|
||||
app: AppId::Tree,
|
||||
bundle: PathBuf::from("/Applications/ElyTree.app"),
|
||||
offer: Some((release("9.0.0", "https://b"), Phase::Fetching(0.5))),
|
||||
dismissed: false,
|
||||
pending: None,
|
||||
note: None,
|
||||
});
|
||||
updates.update(cx, |updates, cx| {
|
||||
updates.fetched(
|
||||
&release("8.0.0", "https://a"),
|
||||
Ok(PathBuf::from("A.dmg")),
|
||||
cx,
|
||||
);
|
||||
assert_eq!(
|
||||
updates.offer.as_ref().map(|(_, phase)| phase.clone()),
|
||||
Some(Phase::Fetching(0.5))
|
||||
);
|
||||
updates.fetched(
|
||||
&release("9.0.0", "https://b"),
|
||||
Ok(PathBuf::from("B.dmg")),
|
||||
cx,
|
||||
);
|
||||
assert_eq!(
|
||||
updates.offer.as_ref().map(|(_, phase)| phase.clone()),
|
||||
Some(Phase::Fetched(PathBuf::from("B.dmg")))
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ batch=${1:-1}
|
||||
dist=${ELY_DIST:-$root/dist}
|
||||
version=$(awk -F'"' '/^version = / { print $2; exit }' "$root/Cargo.toml")
|
||||
arch=$(uname -m)
|
||||
releases=https://git.zacharyzhang.com/ZacharyZhang-NY/ElyOffice/releases
|
||||
[ -z "${ELY_NOTARY_PROFILE:-}" ] || [ -n "${ELY_SIGN_IDENTITY:-}" ] || {
|
||||
echo "release.sh: notarizing needs ELY_SIGN_IDENTITY, a Developer ID" >&2
|
||||
exit 1
|
||||
@@ -28,7 +29,12 @@ awk -F'\t' -v batch="$batch" '$4 == batch { print $1 "\t" $2 }' "$root/packaging
|
||||
codesign --verify --deep --strict "$dist/$name.app"
|
||||
dmg=$dist/$name-$version-$arch.dmg
|
||||
rm -f "$dmg"
|
||||
hdiutil create -quiet -volname "$name" -srcfolder "$dist/$name.app" -format UDZO "$dmg"
|
||||
# The app beside Applications: drag one onto the other.
|
||||
stage=$(mktemp -d)
|
||||
ditto "$dist/$name.app" "$stage/$name.app"
|
||||
ln -s /Applications "$stage/Applications"
|
||||
hdiutil create -quiet -volname "$name" -srcfolder "$stage" -format UDZO "$dmg"
|
||||
rm -rf "$stage"
|
||||
if [ -n "${ELY_NOTARY_PROFILE:-}" ]; then
|
||||
codesign --force --timestamp --sign "$ELY_SIGN_IDENTITY" "$dmg"
|
||||
xcrun notarytool submit "$dmg" --keychain-profile "$ELY_NOTARY_PROFILE" --wait
|
||||
@@ -37,5 +43,11 @@ awk -F'\t' -v batch="$batch" '$4 == batch { print $1 "\t" $2 }' "$root/packaging
|
||||
echo "release.sh: $dmg is not notarized (no ELY_NOTARY_PROFILE)" >&2
|
||||
fi
|
||||
echo "$dmg"
|
||||
# What the app's update check reads from the latest release.
|
||||
sha=$(shasum -a 256 "$dmg" | cut -d' ' -f1)
|
||||
url="$releases/download/v$version/$(basename "$dmg")"
|
||||
printf '{"version": "%s", "assets": {"macos-%s": {"url": "%s", "sha256": "%s"}}}\n' \
|
||||
"$version" "${arch/arm64/aarch64}" "$url" "$sha" >"$dist/$slug.json"
|
||||
echo "$dist/$slug.json"
|
||||
done
|
||||
rm -rf "$root/target/release/incremental"
|
||||
+2
-2
@@ -6,7 +6,7 @@
|
||||
|
||||
次序:S-03、P-01、X-01、D-01、D-02、P-02、P-03、P-04、M-01、X-02、X-03、K-01(Linux、Windows,本机不能运行)、UPD-1、REL-1、REL-2。K-01 不进 0.1.2:只影响 Linux 与 Windows 的 worker,本机不能运行验证,0.1.2 只发 macOS;留在下面的清单里,未勾。T-05 已改一半(审查查出缩放后尺寸不重测),暂放,补丁存在草稿区。
|
||||
|
||||
- [ ] UPD-1 自动更新:应用内检查新版本、下载、校验,经用户同意后替换并重启;发布时生成更新清单,不再要点 GitHub 链接
|
||||
- [x] UPD-1 自动更新:应用内检查新版本、下载、校验,经用户同意后替换并重启;发布时生成更新清单,不再要点 GitHub 链接 — 启动检查默认关、菜单“检查更新…”、退出后再装、失败记录先于重开、经 Gitea API 找清单;R1–R4 授权、提交点、保存询问、版本绑定、横幅位置、取消退出、记录先后;R5 两处(启动检查失败记日志、以替身 open 断言先后)已修并以变异验证,五轮上限,未再审
|
||||
|
||||
- [x] T-01 内核的图块与命中按像素网格,Ely 的光标、选区、表头按文档 twips,越远越错位 — R2.16 已修(图块 twips 与打印 twips 互换)
|
||||
- [x] T-02 单元格光标露出边缘即把视图滚到中央,点击与拖选时视图跳动 — R1 拖选中仍滚、超宽格跳回起点;R2 PASS
|
||||
@@ -104,5 +104,5 @@
|
||||
|
||||
## 0.1.2 发布
|
||||
|
||||
- [ ] REL-1 dmg 里有指向“应用程序”的快捷方式,可直接拖入安装(用户 2026-10-09 报)
|
||||
- [x] REL-1 dmg 里有指向“应用程序”的快捷方式,可直接拖入安装(用户 2026-10-09 报) — release.sh 在 dmg 里放“应用程序”替身,随 UPD-1 审查
|
||||
- [ ] REL-2 修完上列各条后打包、发布 0.1.2,官网指向新发布页
|
||||
@@ -0,0 +1,9 @@
|
||||
FAIL
|
||||
|
||||
crates/ely-ui/src/updates.rs:59 — [P1] 启动检查绕过更新授权与默认设置,违反 PRD.md:113 的“自动检查更新默认关闭”及 QA-25。首次从安装好的 .app 启动、使用空白用户目录时,程序直接请求更新清单;本地端到端测试在任何用户操作之前已收到请求并记录 `offers 9.9.9`。当前设置模型也没有控制该请求的字段。修复方向:加入默认关闭的自动检查设置,启动检查以用户授权和离线设置为条件,并覆盖空白配置下零请求的测试。
|
||||
|
||||
crates/ely-ui/src/updates.rs:67 — [P2] 更新检查仅由启动路径调用一次,缺少 PRD.md:113 要求的手动检查入口。具体场景:启动时断网,唯一一次检查失败后,恢复网络并继续使用应用,用户无法重新检查、获得更新横幅或下载更新;`check` 为私有方法,调用点只有第 59 行,菜单与设置均未接入检查动作。修复方向:提供可重复执行的手动检查动作,接入应用界面,并测试首次失败后联网重查。
|
||||
|
||||
crates/ely-platform/src/install.rs:65 — [P1] 新包替换成功后,删除旧包的错误被当作整个安装失败返回,更新状态与磁盘事实分离,失败路径也未恢复原版本,违反 PRD §4.2、QA-26。实测在旧包 `Contents/version` 上设置 `chflags uchg` 后调用真实 dmg 安装,结果为 `install=Err(PermissionDenied: Operation not permitted)`、`live=Ok("new")`、`backup=Ok("old")`。调用方因此跳过 `reopen_after_exit` 和正常退出,显示更新失败,原路径却已换成新版;重试还会卡在删除同一旧包。修复方向:明确安装提交点,将提交后的备份清理错误与安装结果分开处理,保证提交前失败恢复旧包、提交后结果与实际版本一致,并加入清理失败的回归用例。
|
||||
|
||||
tests/scripts/update.sh:54 — [P2] 重启断言只按命令行中的 .app 路径执行 `pgrep -f`,重启辅助进程也满足条件,测试会把启动失败判为成功。实测仅在临时脚本中通过 PATH 将 `open` 换成 `sleep 30; exit 1`,完整测试仍输出 `ok: ElyTree downloads, installs and reopens its update`;通过断言的唯一进程是 `/bin/sh .../bin/open .../Applications/ElyTree.app`,新版 ElyTree 实际未启动。修复方向:断言新版应用自身的可执行文件身份、不同于旧进程的 PID,以及窗口或启动完成信号;让该 `open` 失败变异稳定触发测试失败。
|
||||
@@ -0,0 +1,7 @@
|
||||
FAIL
|
||||
|
||||
crates/ely-ui/src/updates.rs:195 — [P1] 安装发生在保存与退出确认之前,违反 PRD.md:113 的“更新前要求保存编辑内容”。实测在 ElyDoc 的 Untitled 1 输入文字,下载更新后按 Restart to update:先出现 `install: .../ElyDoc.app replaced`,随后才出现 `Save changes to Untitled 1?`;此时新包标记已在原路径,旧进程仍持有未保存内容。按 Esc 取消后,截图确认文档继续编辑,新包仍在原路径,更新状态停在 Installing,重启入口消失。证据:`/tmp/upd1-r2-save-only.log`、`/tmp/upd1-r2-evidence-3-ely-doc.log`。修复方向:将保存与退出确认作为替换包和注册重启的前置条件;取消时保留原包与可重试的下载完成状态,并覆盖未保存文档取消更新的回归测试。
|
||||
|
||||
crates/ely-ui/src/updates.rs:92 — [P1] 手动重查覆盖正在下载的 release,下载完成回调又直接修改当前 offer 的 phase(第 170–175 行),导致清单版本与待安装文件错配。真实 ElyTree 探针先查询 8.0.0 并开始下载 A.dmg,在 HTTP 服务暂停该下载期间手动重查得到 9.0.0/B.dmg,再放行 A:日志依次记录 `offers 9.0.0`、`update download: 8.0.0 saved .../A.dmg`;随后按 Restart to update,安装后的包标记为 A。用户对新 offer 的重启操作因此安装了此前请求的包。证据:`/tmp/upd1-r2-native.log`、`/tmp/upd1-r2-evidence-0-ely-tree.log`。修复方向:将检查、下载进度、完成结果和安装绑定同一 release 与操作身份,阻止重查覆盖进行中的更新,丢弃过期回调,并覆盖慢下载期间重查的回归测试。
|
||||
|
||||
crates/ely-ui/src/shell/root.rs:250 — [P2] 更新横幅被插在包含 TitleBar 的整个应用外壳之前,落入 macOS 原生窗口按钮所在区域,同时失去外壳提供的主题前景色。实测深色 ElyTree 手动检查得到 9.0.0 时,原生红黄绿按钮遮住更新文案开头;横幅其余文字为 RGB(0,0,0),背景为 RGB(48,48,48),截图实测对比度约 1.59:1,低于 PRD.md:750 的 4.5:1。ElyDoc 的就绪横幅也呈现同样遮挡。证据:`/tmp/upd1-r2-banner.log`。修复方向:将横幅放在主题化外壳的标题栏下方,使用主题前景色并保留原生窗口按钮区域;验证深浅主题下文案可读且无重叠。
|
||||
@@ -0,0 +1,7 @@
|
||||
FAIL
|
||||
|
||||
crates/ely-ui/src/updates.rs:224 — [P1] 取消更新退出后,`pending` 仍保留,下一次普通退出继续安装并重开应用,违反“取消即什么也不装”的修复契约。真实 ElyMark 探针输入未保存文字,下载完成后点击 Restart to update,再点击 Cancel;随后仅从应用菜单选择 Quit ElyMark,并对未保存内容选择 Don't Save,更新标记仍被装入原包,新的应用进程再次启动。日志记录普通退出后的 `update: installs after this quit`;当前回归测试取消后再次点击 Restart,遗漏了这条路径。证据:`/tmp/upd1-r3-cancel.log`、`/tmp/upd1-r3-cancel-app.log`。修复方向:将待安装状态绑定这次更新退出请求,在取消或退出流程中止时清除;保留已下载文件供再次明确点击 Restart,并覆盖取消后普通退出的测试。
|
||||
|
||||
crates/ely-ui/src/updates.rs:131 — [P2] 下载中或已下载时手动检查直接返回,使已关闭的更新横幅无法恢复,下载完成后的安装入口随之丢失。真实 ElyTree 探针下载 8.0.0 完成,关闭横幅的 x,再选择 Check for Updates…;检查函数只记录 `a release is already on its way`,`dismissed` 持续为 true,AX 树中 Restart to update 消失,用户在本次运行中无法重新进入安装操作。下载期间关闭横幅同样进入此状态。证据:`/tmp/upd1-r3-dismiss.log`、`/tmp/upd1-r3-tree-dark.log`。修复方向:用户手动检查时重新显示现有下载状态,同时保持该 release 和下载文件的绑定;覆盖下载中及下载完成后关闭横幅再手动检查的回归测试。
|
||||
|
||||
crates/ely-ui/src/updates.rs:84 — [P2] 安装失败记录的写入错误被 `.ok()` 丢弃,而安装子进程的 stderr 在第 236 行被重定向到空设备,导致失败静默。以下载目录被清理的状态运行真实安装入口:dmg 与 `updates` 目录均已消失,挂载失败后原应用正常重开,写入 `updates/failed.txt` 因父目录缺失失败;重开窗口没有失败提示,记录文件也不存在。保留记录目录的对照探针能够显示同一安装错误。证据:`/tmp/upd1-r3-missing-parent.log`、`/tmp/upd1-r3-missing-parent-reopened.log`、`/tmp/upd1-r3-failure.log`。修复方向:确保故障记录目录存在,处理记录写入错误,并保证持久化失败时重开的应用仍能展示安装失败;覆盖下载目录被清理的安装失败测试。
|
||||
@@ -0,0 +1,3 @@
|
||||
FAIL
|
||||
|
||||
crates/ely-ui/src/updates.rs:95 — [P2] 安装失败记录晚于应用重开,失败提示存在竞态。`ely_platform::install::finish` 在 `crates/ely-platform/src/install.rs:80` 先执行 `open`,返回后此处才写 `updates/failed.txt`;重开的应用只在启动时读取一次该文件(第 119–120 行)。真实 ElyTree 探针令 dmg 缺失,经透明包装调用真实 `/usr/bin/open` 并保持其参数和退出码,在启动请求之后暂停安装父进程;等重开窗口完成启动,再恢复安装进程。连续两次均出现:窗口启动时记录尚未写入,随后安装进程写入记录并退出,当前窗口始终没有失败提示,记录留待下次启动才读取。普通调度对照能生成失败提示,说明此次补建目录仍未保证提示交付。证据:`/tmp/upd1-r4-race-1.log`、`/tmp/upd1-r4-race-2.log`、`/tmp/upd1-r4-race-app.log`;复现步骤:`/tmp/upd1-r4-repro.log`。修复方向:失败记录完成写入及错误处理后再重开应用,并增加覆盖该先后顺序、断言本次重开即显示失败提示的回归测试。
|
||||
@@ -0,0 +1,5 @@
|
||||
FAIL
|
||||
|
||||
crates/ely-ui/src/updates.rs:203 — [P2] 启动自动检查会静默丢弃新增 Gitea 查询路径的错误。`manifest(app)` 在第 188 行先执行,查询失败便跳过负责记录结果的 `update::check`;`asked == false` 时错误进入第 204 行的空分支。实测将临时 HOME 的 `check_updates` 设为 true,启动本轮构建并打包的 ElyTree:Gitea 请求完成并返回 HTTP 200,当前发布缺少 `ely-tree.json`,直接调用生产 `release::asset_url` 得到 `release v0.1.1 has no ely-tree.json`,启动日志却完全没有检查失败记录。API 断网、HTTP 错误和无效响应同样进入此分支,违反出错暴露和关键路径日志规则。证据:`/tmp/upd1-r5-gitea-debug.log`、`/tmp/upd1-r5-gitea-error.log`、`/tmp/upd1-r5-repro.log`。修复方向:对整个 Gitea 查询与清单检查的失败结果记录错误,并覆盖启动查询失败的日志断言。
|
||||
|
||||
tests/scripts/update.sh:115 — [P2] 新增顺序回归断言将记录修改时间与进程启动时间都截到整秒,同一秒内先重开、后写记录仍会通过。第 4 轮的生产代码顺序已修正,原版真实 ElyMark 更新脚本通过;随后在临时测试脚本的损坏发布阶段,通过 hdiutil 包装器执行真实 detach,再提前调用真实 `/usr/bin/open`,确认真实应用进程已出现后才让安装器继续写记录,保留全部原有断言。变异日志在 `1791577204130723000` 纳秒确认应用已重开且记录尚不存在,记录修改时间为 `1791577204133946137` 纳秒,完整脚本仍退出 0 并输出成功。独立真实进程探针连续三次也接受了启动后约 250 毫秒才写记录的顺序。证据:`/tmp/upd1-r5-mutation-order.log`、`/tmp/upd1-r5-mutation-test.log`、`/tmp/upd1-r5-time-assertion.log`;复现方法:`/tmp/upd1-r5-repro.log`。修复方向:通过重开入口的事件握手断言启动请求前记录已写好,并验证本次重开窗口显示失败提示,使提前重开的变异稳定失败。
|
||||
Executable
+120
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env bash
|
||||
# An installed ElyMark updates from a served release, asking first.
|
||||
set -euo pipefail
|
||||
|
||||
root=$(cd "$(dirname "$0")/../.." && pwd)
|
||||
[ "$(uname)" = Darwin ] || { echo "SKIP: macOS only"; exit 0; }
|
||||
work=$(mktemp -d)
|
||||
cleanup() {
|
||||
pkill -f "$work/Applications/ElyMark.app" 2>/dev/null || true
|
||||
if [ -n "${server:-}" ]; then
|
||||
kill "$server" 2>/dev/null || true
|
||||
wait "$server" 2>/dev/null || true
|
||||
fi
|
||||
rm -rf "$work"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
fail() { echo "FAIL: $*" >&2; exit 1; }
|
||||
|
||||
ELY_DIST="$work/dist" "$root/packaging/macos/bundle.sh" ely-mark debug >"$work/bundle" 2>&1 || fail "bundle: $(tail -5 "$work/bundle")"
|
||||
swiftc -O -o "$work/ax-press" "$root/tests/scripts/ax-press.swift"
|
||||
mkdir -p "$work/Applications" "$work/new" "$work/serve"
|
||||
ditto "$work/dist/ElyMark.app" "$work/Applications/ElyMark.app"
|
||||
# The new release: the same app, marked.
|
||||
ditto "$work/dist/ElyMark.app" "$work/new/ElyMark.app"
|
||||
touch "$work/new/ElyMark.app/Contents/Resources/ely-update-marker"
|
||||
codesign --force --deep --sign - "$work/new/ElyMark.app" 2>/dev/null
|
||||
ln -s /Applications "$work/new/Applications"
|
||||
hdiutil create -quiet -volname ElyMark -srcfolder "$work/new" -format UDZO "$work/serve/ElyMark.dmg"
|
||||
port=$(python3 -c 'import socket; s = socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1])')
|
||||
sha=$(shasum -a 256 "$work/serve/ElyMark.dmg" | cut -d' ' -f1)
|
||||
key="macos-$(uname -m | sed s/arm64/aarch64/)"
|
||||
printf '{"version": "9.9.9", "assets": {"%s": {"url": "http://127.0.0.1:%s/ElyMark.dmg", "sha256": "%s"}}}\n' "$key" "$port" "$sha" >"$work/serve/ely-mark.json"
|
||||
python3 -m http.server "$port" --bind 127.0.0.1 --directory "$work/serve" >"$work/served" 2>&1 &
|
||||
server=$!
|
||||
mkdir -p "$work/home"
|
||||
# The installer's open: says whether the failure note came first.
|
||||
mkdir -p "$work/bin"
|
||||
printf '#!/bin/sh\n[ -e "%s" ] && echo before >>"%s" || echo after >>"%s"\nexec /usr/bin/open "$@"\n' \
|
||||
"$work/home/Library/Application Support/ElyOffice/ely-mark/updates/failed.txt" "$work/opened" "$work/opened" >"$work/bin/open"
|
||||
chmod +x "$work/bin/open"
|
||||
start() {
|
||||
PATH="$work/bin:$PATH" HOME="$work/home" ELY_LOG=info ELY_LOCALE=en ELY_NO_DIALOG=1 ELY_UPDATE_MANIFEST="http://127.0.0.1:$port/ely-mark.json" \
|
||||
"$work/Applications/ElyMark.app/Contents/MacOS/ely-mark" >"$work/out" 2>&1 &
|
||||
pid=$!
|
||||
}
|
||||
start
|
||||
[ "$("$work/ax-press" "$pid" none)" != untrusted ] || fail "needs Accessibility permission"
|
||||
# Off by default: nothing is asked at launch.
|
||||
for _ in $(seq 60); do grep -q "no check at launch" "$work/out" && break; sleep 0.5; done
|
||||
grep -q "no check at launch" "$work/out" || fail "the launch check ran: $(grep update "$work/out" | tail -3)"
|
||||
for _ in $(seq 60); do grep -q 'mark: shown as' "$work/out" && break; sleep 0.5; done
|
||||
# A new untitled page: it takes keys, autosave skips it.
|
||||
osascript -e "tell application \"System Events\" to set frontmost of (first process whose unix id is $pid) to true" -e 'delay 1' \
|
||||
-e 'tell application "System Events" to keystroke "n" using command down' -e 'delay 2' \
|
||||
-e 'tell application "System Events" to keystroke "unsaved"' >/dev/null
|
||||
sleep 3
|
||||
! grep -q GET "$work/served" || fail "asked without being told: $(cat "$work/served")"
|
||||
press() {
|
||||
for _ in $(seq 60); do
|
||||
[ "$("$work/ax-press" "$pid" "$1")" = pressed ] && return 0
|
||||
sleep 0.5
|
||||
done
|
||||
fail "could not press $1: $(grep update "$work/out" | tail -3)"
|
||||
}
|
||||
press "Check for Updates…"
|
||||
for _ in $(seq 60); do grep -q "offers 9.9.9" "$work/out" && break; sleep 0.5; done
|
||||
grep -q "offers 9.9.9" "$work/out" || fail "no offer: $(grep update "$work/out" | tail -3)"
|
||||
press Download
|
||||
press "Restart to update"
|
||||
# Unsaved text: the question comes first; cancel installs nothing.
|
||||
press Cancel
|
||||
sleep 3
|
||||
kill -0 "$pid" 2>/dev/null || fail "a cancelled quit still quit"
|
||||
[ ! -e "$work/Applications/ElyMark.app/Contents/Resources/ely-update-marker" ] || fail "installed before the question"
|
||||
# A plain quit after the cancel installs nothing either.
|
||||
osascript -e 'tell application "System Events" to keystroke "q" using command down' >/dev/null
|
||||
press "Don't Save"
|
||||
for _ in $(seq 60); do kill -0 "$pid" 2>/dev/null || break; sleep 0.5; done
|
||||
! kill -0 "$pid" 2>/dev/null || fail "the app did not quit"
|
||||
sleep 8
|
||||
[ ! -e "$work/Applications/ElyMark.app/Contents/Resources/ely-update-marker" ] || fail "a plain quit installed the update"
|
||||
! pgrep -f "$(basename "$work")/Applications/ElyMark.app/Contents/MacOS/ely-mark" >/dev/null || fail "a plain quit reopened the app"
|
||||
# Relaunched: check, download again, and Restart installs.
|
||||
start
|
||||
for _ in $(seq 60); do grep -q 'mark: shown as' "$work/out" && break; sleep 0.5; done
|
||||
press "Check for Updates…"
|
||||
press Download
|
||||
press "Restart to update"
|
||||
for _ in $(seq 60); do kill -0 "$pid" 2>/dev/null || break; sleep 0.5; done
|
||||
! kill -0 "$pid" 2>/dev/null || fail "the old app did not quit"
|
||||
# The installer copies once the old app has gone.
|
||||
for _ in $(seq 120); do [ -e "$work/Applications/ElyMark.app/Contents/Resources/ely-update-marker" ] && break; sleep 0.5; done
|
||||
[ -e "$work/Applications/ElyMark.app/Contents/Resources/ely-update-marker" ] || fail "the new app is not in place"
|
||||
# The new app itself, by the path the system resolved.
|
||||
binary="$(basename "$work")/Applications/ElyMark.app/Contents/MacOS/ely-mark"
|
||||
for _ in $(seq 40); do pgrep -f "$binary" >/dev/null && break; sleep 0.5; done
|
||||
fresh=$(pgrep -f "$binary" | head -n 1 || true)
|
||||
[ -n "$fresh" ] && [ "$fresh" != "$pid" ] || fail "the new app did not open"
|
||||
[ "$(ls -A "$work/Applications")" = ElyMark.app ] || fail "copies left: $(ls -A "$work/Applications")"
|
||||
# A broken release: its note is written before the reopen.
|
||||
pkill -f "$binary" || true
|
||||
for _ in $(seq 20); do pgrep -f "$binary" >/dev/null || break; sleep 0.5; done
|
||||
mkdir -p "$work/broken/Other.app"
|
||||
hdiutil create -quiet -volname Broken -srcfolder "$work/broken" -format UDZO "$work/serve/Broken.dmg"
|
||||
sha=$(shasum -a 256 "$work/serve/Broken.dmg" | cut -d' ' -f1)
|
||||
rm -f "$work/opened"
|
||||
printf '{"version": "9.9.10", "assets": {"%s": {"url": "http://127.0.0.1:%s/Broken.dmg", "sha256": "%s"}}}\n' "$key" "$port" "$sha" >"$work/serve/ely-mark.json"
|
||||
start
|
||||
for _ in $(seq 60); do grep -q 'mark: shown as' "$work/out" && break; sleep 0.5; done
|
||||
press "Check for Updates…"
|
||||
press Download
|
||||
press "Restart to update"
|
||||
note="$work/home/Library/Application Support/ElyOffice/ely-mark/updates/failed.txt"
|
||||
# Not the old process, which this script started and reaps.
|
||||
reopened() { pgrep -f "$binary" | grep -vx "$pid" | head -n 1 || true; }
|
||||
for _ in $(seq 60); do [ -n "$(reopened)" ] && [ -e "$note" ] && break; sleep 0.5; done
|
||||
grep -q "holds no ElyMark.app" "$note" || fail "no failure note: $(cat "$note" 2>/dev/null)"
|
||||
[ -n "$(reopened)" ] || fail "the old app did not reopen"
|
||||
[ "$(head -n 1 "$work/opened")" = before ] || fail "the app reopened before the note was written"
|
||||
echo "ok: ElyMark downloads, installs and reopens its update"
|
||||
Reference in new issue
Block a user