send app store builds to the store for updates

This commit is contained in:
pj committed 2026-09-01 23:17:33 +05:30
1 parent e8772e6699
commit e8555c7ef9
10 files changed
+199 -54

No files matched your search

+28 -4
View File
@@ -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 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. 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 ## The Mac App Store
Tauri has no App Store target, so `scripts/mas-package.sh` covers the distance between the `.app` 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 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 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 API and the version lookup above. `network.server` is the loopback listener the Drive OAuth flow
installed-app flow Google still supports. 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 `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 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 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 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 by construction. The menu item stays, pointed at the App Store instead, which is what the previous
item that errors when clicked is worse than an absent one and is its own rejection risk. section is about.
The library moves. Sandboxed, `app_data_dir()` resolves inside The library moves. Sandboxed, `app_data_dir()` resolves inside
`~/Library/Containers/studio.margin.app`, not `~/Library/Application Support`. Someone who switches `~/Library/Containers/studio.margin.app`, not `~/Library/Application Support`. Someone who switches
+1
View File
@@ -4723,6 +4723,7 @@ dependencies = [
"objc2-foundation", "objc2-foundation",
"rand 0.8.6", "rand 0.8.6",
"reqwest 0.12.28", "reqwest 0.12.28",
"semver",
"serde", "serde",
"serde_json", "serde_json",
"sha2", "sha2",
+1
View File
@@ -38,6 +38,7 @@ typst-as-lib = "0.15.5"
harper-core = { version = "=2.5.0", features = ["concurrent"] } harper-core = { version = "=2.5.0", features = ["concurrent"] }
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] } reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
sha2 = "0.10" sha2 = "0.10"
semver = "1"
rand = "0.8" rand = "0.8"
url = "2" url = "2"
+2 -1
View File
@@ -5,7 +5,8 @@
<key>com.apple.security.app-sandbox</key> <key>com.apple.security.app-sandbox</key>
<true/> <true/>
<!-- Google Drive backup talks to googleapis.com. Nothing else leaves the machine. --> <!-- Google Drive backup talks to googleapis.com, and the update check asks itunes.apple.com
which version is live on the store. Nothing else leaves the machine. -->
<key>com.apple.security.network.client</key> <key>com.apple.security.network.client</key>
<true/> <true/>
+6 -6
View File
@@ -7,6 +7,7 @@ mod macspell;
mod pdf; mod pdf;
mod project; mod project;
mod proofing; mod proofing;
mod updates;
mod writingtools; mod writingtools;
#[cfg(desktop)] #[cfg(desktop)]
@@ -30,11 +31,7 @@ fn build_menu<R: Runtime>(handle: &tauri::AppHandle<R>) -> tauri::Result<Menu<R>
let export_epub = MenuItemBuilder::with_id("export-epub", "Export as EPUB…") let export_epub = MenuItemBuilder::with_id("export-epub", "Export as EPUB…")
.accelerator("CmdOrCtrl+Shift+E") .accelerator("CmdOrCtrl+Shift+E")
.build(handle)?; .build(handle)?;
let check_updates = handle let check_updates = (updates::channel(handle) != "none")
.config()
.plugins
.0
.contains_key("updater")
.then(|| MenuItemBuilder::with_id("check-updates", "Check for Updates…").build(handle)) .then(|| MenuItemBuilder::with_id("check-updates", "Check for Updates…").build(handle))
.transpose()?; .transpose()?;
let settings = MenuItemBuilder::with_id("settings", "Settings…") let settings = MenuItemBuilder::with_id("settings", "Settings…")
@@ -223,7 +220,10 @@ pub fn run() {
gdrive::gdrive_backup, gdrive::gdrive_backup,
gdrive::gdrive_sync, gdrive::gdrive_sync,
gdrive::gdrive_restore, gdrive::gdrive_restore,
gdrive::gdrive_list_backups gdrive::gdrive_list_backups,
updates::update_channel,
updates::appstore_latest,
updates::open_appstore
]) ])
.build(context) .build(context)
.expect("error while building margin"); .expect("error while building margin");
+90
View File
@@ -0,0 +1,90 @@
use std::sync::LazyLock;
use std::time::{SystemTime, UNIX_EPOCH};
use tauri::{AppHandle, Runtime};
static HTTP: LazyLock<reqwest::Client> = 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<R: Runtime>(handle: &AppHandle<R>) -> &'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<Option<AppStoreRelease>, 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())
}
+3
View File
@@ -5,5 +5,8 @@
"macOS": { "macOS": {
"hardenedRuntime": true "hardenedRuntime": true
} }
},
"plugins": {
"appstore": {}
} }
} }
+1 -24
View File
@@ -1,13 +1,12 @@
import { useRef } from "react"; import { useRef } from "react";
import { useUpdater } from "../store/useUpdater"; import { useUpdater } from "../store/useUpdater";
import { installUpdate, dismissUpdate } from "../updater"; import { dismissUpdate } from "../updater";
import { useEscapeLayer } from "../escape"; import { useEscapeLayer } from "../escape";
import { useFocusTrap } from "../focus"; import { useFocusTrap } from "../focus";
import { Icon } from "./Icon"; import { Icon } from "./Icon";
const TITLES: Record<string, string> = { const TITLES: Record<string, string> = {
checking: "Check for Updates", checking: "Check for Updates",
available: "Update Available",
downloading: "Updating margin", downloading: "Updating margin",
installing: "Updating margin", installing: "Updating margin",
uptodate: "Check for Updates", uptodate: "Check for Updates",
@@ -16,8 +15,6 @@ const TITLES: Record<string, string> = {
export function UpdateDialog() { export function UpdateDialog() {
const phase = useUpdater((s) => s.phase); 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 downloaded = useUpdater((s) => s.downloaded);
const total = useUpdater((s) => s.total); const total = useUpdater((s) => s.total);
const error = useUpdater((s) => s.error); const error = useUpdater((s) => s.error);
@@ -54,15 +51,6 @@ export function UpdateDialog() {
{phase === "uptodate" && <p className="confirm-text">margin is up to date.</p>} {phase === "uptodate" && <p className="confirm-text">margin is up to date.</p>}
{phase === "available" && (
<>
<p className="confirm-text">
<strong>margin {version}</strong> is available.
</p>
{notes && <div className="update-notes">{notes}</div>}
</>
)}
{phase === "downloading" && ( {phase === "downloading" && (
<> <>
<div className={`update-progress${total > 0 ? "" : " indeterminate"}`}> <div className={`update-progress${total > 0 ? "" : " indeterminate"}`}>
@@ -87,17 +75,6 @@ export function UpdateDialog() {
)} )}
</div> </div>
{phase === "available" && (
<div className="panel-foot">
<button className="btn-ghost" onClick={dismissUpdate}>
Later
</button>
<button className="btn-primary" onClick={installUpdate}>
Install &amp; Restart
</button>
</div>
)}
{(phase === "uptodate" || phase === "error") && ( {(phase === "uptodate" || phase === "error") && (
<div className="panel-foot"> <div className="panel-foot">
<button className="btn-primary" onClick={dismissUpdate}> <button className="btn-primary" onClick={dismissUpdate}>
-5
View File
@@ -4,7 +4,6 @@ import type { Update } from "@tauri-apps/plugin-updater";
export type UpdatePhase = export type UpdatePhase =
| "idle" | "idle"
| "checking" | "checking"
| "available"
| "downloading" | "downloading"
| "installing" | "installing"
| "uptodate" | "uptodate"
@@ -12,8 +11,6 @@ export type UpdatePhase =
interface UpdaterState { interface UpdaterState {
phase: UpdatePhase; phase: UpdatePhase;
version: string;
notes: string;
downloaded: number; downloaded: number;
total: number; total: number;
error: string; error: string;
@@ -24,8 +21,6 @@ interface UpdaterState {
const initial = { const initial = {
phase: "idle" as UpdatePhase, phase: "idle" as UpdatePhase,
version: "",
notes: "",
downloaded: 0, downloaded: 0,
total: 0, total: 0,
error: "", error: "",
+67 -14
View File
@@ -1,8 +1,38 @@
import { check } from "@tauri-apps/plugin-updater"; import { check } from "@tauri-apps/plugin-updater";
import { relaunch } from "@tauri-apps/plugin-process"; import { relaunch } from "@tauri-apps/plugin-process";
import { ask } from "@tauri-apps/plugin-dialog";
import { invoke } from "@tauri-apps/api/core";
import { getVersion } from "@tauri-apps/api/app";
import { useUpdater } from "./store/useUpdater"; import { useUpdater } from "./store/useUpdater";
const DECLINED = "margin.update.declined";
type Channel = "direct" | "appstore" | "none";
let checking = false; let checking = false;
let channel: Channel | null = null;
async function updateChannel() {
if (!channel) channel = await invoke<Channel>("update_channel");
return channel;
}
function declined(version: string) {
return localStorage.getItem(DECLINED) === version;
}
function decline(version: string) {
localStorage.setItem(DECLINED, version);
}
function offer(version: string, current: string, okLabel: string) {
return ask(`margin ${version} is available. You have ${current}.`, {
title: "A new version of margin is available",
kind: "info",
okLabel,
cancelLabel: "Later",
});
}
export async function checkForUpdates(silent: boolean) { export async function checkForUpdates(silent: boolean) {
if (checking) return; if (checking) return;
@@ -10,20 +40,10 @@ export async function checkForUpdates(silent: boolean) {
const store = useUpdater.getState(); const store = useUpdater.getState();
if (!silent) store.set({ phase: "checking", error: "" }); if (!silent) store.set({ phase: "checking", error: "" });
try { try {
const update = await check(); const target = await updateChannel();
if (!update) { if (target === "direct") await checkDirect(silent);
store.set(silent ? { phase: "idle" } : { phase: "uptodate" }); else if (target === "appstore") await checkAppStore(silent);
return; else store.set(silent ? { phase: "idle" } : { phase: "uptodate" });
}
store.set({
phase: "available",
update,
version: update.version,
notes: update.body ?? "",
downloaded: 0,
total: 0,
error: "",
});
} catch (err) { } catch (err) {
store.set(silent ? { phase: "idle" } : { phase: "error", error: String(err) }); store.set(silent ? { phase: "idle" } : { phase: "error", error: String(err) });
} finally { } finally {
@@ -31,6 +51,39 @@ export async function checkForUpdates(silent: boolean) {
} }
} }
async function checkDirect(silent: boolean) {
const store = useUpdater.getState();
const update = await check();
if (!update) {
store.set(silent ? { phase: "idle" } : { phase: "uptodate" });
return;
}
if (silent && declined(update.version)) {
store.set({ phase: "idle" });
return;
}
store.set({ phase: "idle", update });
if (await offer(update.version, update.currentVersion, "Install Update")) await installUpdate();
else decline(update.version);
}
async function checkAppStore(silent: boolean) {
const store = useUpdater.getState();
const release = await invoke<{ version: string; trackId: number } | null>("appstore_latest");
if (!release) {
store.set(silent ? { phase: "idle" } : { phase: "uptodate" });
return;
}
if (silent && declined(release.version)) {
store.set({ phase: "idle" });
return;
}
store.set({ phase: "idle" });
if (await offer(release.version, await getVersion(), "Open App Store"))
await invoke("open_appstore", { trackId: release.trackId });
else decline(release.version);
}
export async function installUpdate() { export async function installUpdate() {
const { update, phase } = useUpdater.getState(); const { update, phase } = useUpdater.getState();
if (!update || phase === "downloading" || phase === "installing") return; if (!update || phase === "downloading" || phase === "installing") return;