From e8555c7ef9f846c9de9942bcb3d53e95c1ec7ac8 Mon Sep 17 00:00:00 2001 From: PJ Date: Tue, 1 Sep 2026 23:15:32 +0530 Subject: [PATCH] send app store builds to the store for updates --- docs/publishing.md | 32 +++++++++-- src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 1 + src-tauri/entitlements.mas.plist | 3 +- src-tauri/src/lib.rs | 12 ++-- src-tauri/src/updates.rs | 90 ++++++++++++++++++++++++++++++ src-tauri/tauri.appstore.conf.json | 3 + src/components/UpdateDialog.tsx | 25 +-------- src/store/useUpdater.ts | 5 -- src/updater.ts | 81 ++++++++++++++++++++++----- 10 files changed, 199 insertions(+), 54 deletions(-) create mode 100644 src-tauri/src/updates.rs diff --git a/docs/publishing.md b/docs/publishing.md index 2766be2..c2d54bc 100644 --- a/docs/publishing.md +++ b/docs/publishing.md @@ -57,6 +57,30 @@ That secret is an SSH deploy key registered on the tap, not a personal access to carry the whole account; the deploy key reaches the tap and nothing else, so a leak from a release job cannot touch the app repositories. +## Updates + +Both builds check on launch and from the same "Check for Updates…" menu item, and they ask +different questions. The direct download asks the updater endpoint in `tauri.release.conf.json`, +downloads the new bundle and restarts. The App Store copy asks `itunes.apple.com/lookup` which +version is live under the bundle id, and if that is ahead of the one running it offers to open the +store page, because an App Store app may not install code and would be rejected for trying. + +Which of the two runs is decided in `updates.rs` by the marker the config carries: the release +overlay declares an `updater` plugin, the App Store overlay declares an `appstore` one. A +`_MASReceipt` inside the bundle overrides both. That is the check that matters, because it means a +bundle which came from the store cannot self-update even if it was built with the updater in it, +and the decision does not rest on a config file alone. + +The prompt is a native alert rather than a window the app draws, and it appears once per version. +"Later" means later, not later today: nothing is raised again until there is a new version to raise, +and the menu item is there in the meantime. Whether to update is the reader's call, and an app that +asks the same question at every launch is answering it for them. + +The store copy trails the direct one by however long review takes, so an App Store user being told +they are up to date while GitHub has something newer is correct rather than a bug. Each channel +compares against its own. Apple's lookup endpoint is also edge cached and can sit a few hours behind +a release going live, which is what the timestamp on the request is for. + ## The Mac App Store Tauri has no App Store target, so `scripts/mas-package.sh` covers the distance between the `.app` @@ -136,8 +160,8 @@ choose. Do not remove that. The App Store build declares four entitlements, in `src-tauri/entitlements.mas.plist`, and each one is there for a reason worth being able to defend in review. `network.client` is the Google Drive -API. `network.server` is the loopback listener the Drive OAuth flow redirects to, which is the only -installed-app flow Google still supports. +API and the version lookup above. `network.server` is the loopback listener the Drive OAuth flow +redirects to, which is the only installed-app flow Google still supports. `network.server` is not a theoretical risk. An automated check rejects any submission that declares it, before review, unless the App Review Information says what listens and why, so @@ -154,8 +178,8 @@ Three things are different in that build, and all three are Apple's rules rather The updater is gone. `lib.rs` registers the updater plugin only when the config declares it, and only `tauri.release.conf.json` does, so building against `tauri.appstore.conf.json` leaves it out -by construction. The "Check for Updates" menu item is gated on the same condition, because a menu -item that errors when clicked is worse than an absent one and is its own rejection risk. +by construction. The menu item stays, pointed at the App Store instead, which is what the previous +section is about. The library moves. Sandboxed, `app_data_dir()` resolves inside `~/Library/Containers/studio.margin.app`, not `~/Library/Application Support`. Someone who switches diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 8174b8b..67dcda4 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4723,6 +4723,7 @@ dependencies = [ "objc2-foundation", "rand 0.8.6", "reqwest 0.12.28", + "semver", "serde", "serde_json", "sha2", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e3cd812..8f9afb1 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -38,6 +38,7 @@ typst-as-lib = "0.15.5" harper-core = { version = "=2.5.0", features = ["concurrent"] } reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] } sha2 = "0.10" +semver = "1" rand = "0.8" url = "2" diff --git a/src-tauri/entitlements.mas.plist b/src-tauri/entitlements.mas.plist index 15d5fa8..d89ce82 100644 --- a/src-tauri/entitlements.mas.plist +++ b/src-tauri/entitlements.mas.plist @@ -5,7 +5,8 @@ com.apple.security.app-sandbox - + com.apple.security.network.client diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8a70a9e..2644fb9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -7,6 +7,7 @@ mod macspell; mod pdf; mod project; mod proofing; +mod updates; mod writingtools; #[cfg(desktop)] @@ -30,11 +31,7 @@ fn build_menu(handle: &tauri::AppHandle) -> tauri::Result let export_epub = MenuItemBuilder::with_id("export-epub", "Export as EPUB…") .accelerator("CmdOrCtrl+Shift+E") .build(handle)?; - let check_updates = handle - .config() - .plugins - .0 - .contains_key("updater") + let check_updates = (updates::channel(handle) != "none") .then(|| MenuItemBuilder::with_id("check-updates", "Check for Updates…").build(handle)) .transpose()?; let settings = MenuItemBuilder::with_id("settings", "Settings…") @@ -223,7 +220,10 @@ pub fn run() { gdrive::gdrive_backup, gdrive::gdrive_sync, gdrive::gdrive_restore, - gdrive::gdrive_list_backups + gdrive::gdrive_list_backups, + updates::update_channel, + updates::appstore_latest, + updates::open_appstore ]) .build(context) .expect("error while building margin"); diff --git a/src-tauri/src/updates.rs b/src-tauri/src/updates.rs new file mode 100644 index 0000000..ae551d1 --- /dev/null +++ b/src-tauri/src/updates.rs @@ -0,0 +1,90 @@ +use std::sync::LazyLock; +use std::time::{SystemTime, UNIX_EPOCH}; + +use tauri::{AppHandle, Runtime}; + +static HTTP: LazyLock = LazyLock::new(reqwest::Client::new); + +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AppStoreRelease { + version: String, + track_id: u64, +} + +// The build flavour decides this, but a Mac App Store receipt overrides it either way: a bundle +// carrying one must never self-update, whatever config it was built against. +pub fn channel(handle: &AppHandle) -> &'static str { + let plugins = &handle.config().plugins.0; + if plugins.contains_key("updater") && !mas_receipt() { + "direct" + } else if mas_receipt() || plugins.contains_key("appstore") { + "appstore" + } else { + "none" + } +} + +#[cfg(target_os = "macos")] +fn mas_receipt() -> bool { + let Ok(exe) = std::env::current_exe() else { + return false; + }; + exe.parent() + .and_then(|macos| macos.parent()) + .map(|contents| contents.join("_MASReceipt").join("receipt").exists()) + .unwrap_or(false) +} + +#[cfg(not(target_os = "macos"))] +fn mas_receipt() -> bool { + false +} + +#[tauri::command] +pub fn update_channel(app: AppHandle) -> &'static str { + channel(&app) +} + +// Apple's lookup endpoint is edge cached, so a release can take hours to show up here. The +// timestamp is the usual way past that. +#[tauri::command] +pub async fn appstore_latest(app: AppHandle) -> Result, String> { + let bundle_id = app.config().identifier.clone(); + let current = app.package_info().version.clone(); + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let url = format!("https://itunes.apple.com/lookup?bundleId={bundle_id}&t={stamp}"); + + let body: serde_json::Value = HTTP + .get(url) + .send() + .await + .map_err(|e| e.to_string())? + .json() + .await + .map_err(|e| e.to_string())?; + + let Some(result) = body["results"].get(0) else { + return Ok(None); + }; + let (Some(version), Some(track_id)) = (result["version"].as_str(), result["trackId"].as_u64()) + else { + return Ok(None); + }; + let Ok(latest) = semver::Version::parse(version) else { + return Ok(None); + }; + if latest <= current { + return Ok(None); + } + Ok(Some(AppStoreRelease { version: version.to_string(), track_id })) +} + +#[tauri::command] +pub fn open_appstore(track_id: u64) -> Result<(), String> { + let url = format!("macappstore://apps.apple.com/app/id{track_id}"); + tauri_plugin_opener::open_url(url, None::<&str>).map_err(|e| e.to_string()) +} diff --git a/src-tauri/tauri.appstore.conf.json b/src-tauri/tauri.appstore.conf.json index a5d44e6..1c7544a 100644 --- a/src-tauri/tauri.appstore.conf.json +++ b/src-tauri/tauri.appstore.conf.json @@ -5,5 +5,8 @@ "macOS": { "hardenedRuntime": true } + }, + "plugins": { + "appstore": {} } } diff --git a/src/components/UpdateDialog.tsx b/src/components/UpdateDialog.tsx index da26a66..1bca60f 100644 --- a/src/components/UpdateDialog.tsx +++ b/src/components/UpdateDialog.tsx @@ -1,13 +1,12 @@ import { useRef } from "react"; import { useUpdater } from "../store/useUpdater"; -import { installUpdate, dismissUpdate } from "../updater"; +import { dismissUpdate } from "../updater"; import { useEscapeLayer } from "../escape"; import { useFocusTrap } from "../focus"; import { Icon } from "./Icon"; const TITLES: Record = { checking: "Check for Updates", - available: "Update Available", downloading: "Updating margin", installing: "Updating margin", uptodate: "Check for Updates", @@ -16,8 +15,6 @@ const TITLES: Record = { export function UpdateDialog() { const phase = useUpdater((s) => s.phase); - const version = useUpdater((s) => s.version); - const notes = useUpdater((s) => s.notes); const downloaded = useUpdater((s) => s.downloaded); const total = useUpdater((s) => s.total); const error = useUpdater((s) => s.error); @@ -54,15 +51,6 @@ export function UpdateDialog() { {phase === "uptodate" &&

margin is up to date.

} - {phase === "available" && ( - <> -

- margin {version} is available. -

- {notes &&
{notes}
} - - )} - {phase === "downloading" && ( <>
0 ? "" : " indeterminate"}`}> @@ -87,17 +75,6 @@ export function UpdateDialog() { )}
- {phase === "available" && ( -
- - -
- )} - {(phase === "uptodate" || phase === "error") && (