mirror of
https://github.com/priyanshujain/margin.git
synced 2026-10-02 11:07:04 +00:00
send app store builds to the store for updates
This commit is contained in:
1 parent
e8772e6699
commit
e8555c7ef9
10 files changed
+199
-54
No files matched your search
+28
-4
@@ -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
|
||||
|
||||
Generated
+1
@@ -4723,6 +4723,7 @@ dependencies = [
|
||||
"objc2-foundation",
|
||||
"rand 0.8.6",
|
||||
"reqwest 0.12.28",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<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>
|
||||
<true/>
|
||||
|
||||
|
||||
@@ -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<R: Runtime>(handle: &tauri::AppHandle<R>) -> tauri::Result<Menu<R>
|
||||
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");
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -5,5 +5,8 @@
|
||||
"macOS": {
|
||||
"hardenedRuntime": true
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"appstore": {}
|
||||
}
|
||||
}
|
||||
@@ -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<string, string> = {
|
||||
checking: "Check for Updates",
|
||||
available: "Update Available",
|
||||
downloading: "Updating margin",
|
||||
installing: "Updating margin",
|
||||
uptodate: "Check for Updates",
|
||||
@@ -16,8 +15,6 @@ const TITLES: Record<string, string> = {
|
||||
|
||||
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" && <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" && (
|
||||
<>
|
||||
<div className={`update-progress${total > 0 ? "" : " indeterminate"}`}>
|
||||
@@ -87,17 +75,6 @@ export function UpdateDialog() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{phase === "available" && (
|
||||
<div className="panel-foot">
|
||||
<button className="btn-ghost" onClick={dismissUpdate}>
|
||||
Later
|
||||
</button>
|
||||
<button className="btn-primary" onClick={installUpdate}>
|
||||
Install & Restart
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(phase === "uptodate" || phase === "error") && (
|
||||
<div className="panel-foot">
|
||||
<button className="btn-primary" onClick={dismissUpdate}>
|
||||
|
||||
@@ -4,7 +4,6 @@ import type { Update } from "@tauri-apps/plugin-updater";
|
||||
export type UpdatePhase =
|
||||
| "idle"
|
||||
| "checking"
|
||||
| "available"
|
||||
| "downloading"
|
||||
| "installing"
|
||||
| "uptodate"
|
||||
@@ -12,8 +11,6 @@ export type UpdatePhase =
|
||||
|
||||
interface UpdaterState {
|
||||
phase: UpdatePhase;
|
||||
version: string;
|
||||
notes: string;
|
||||
downloaded: number;
|
||||
total: number;
|
||||
error: string;
|
||||
@@ -24,8 +21,6 @@ interface UpdaterState {
|
||||
|
||||
const initial = {
|
||||
phase: "idle" as UpdatePhase,
|
||||
version: "",
|
||||
notes: "",
|
||||
downloaded: 0,
|
||||
total: 0,
|
||||
error: "",
|
||||
|
||||
+67
-14
@@ -1,8 +1,38 @@
|
||||
import { check } from "@tauri-apps/plugin-updater";
|
||||
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";
|
||||
|
||||
const DECLINED = "margin.update.declined";
|
||||
|
||||
type Channel = "direct" | "appstore" | "none";
|
||||
|
||||
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) {
|
||||
if (checking) return;
|
||||
@@ -10,20 +40,10 @@ export async function checkForUpdates(silent: boolean) {
|
||||
const store = useUpdater.getState();
|
||||
if (!silent) store.set({ phase: "checking", error: "" });
|
||||
try {
|
||||
const update = await check();
|
||||
if (!update) {
|
||||
store.set(silent ? { phase: "idle" } : { phase: "uptodate" });
|
||||
return;
|
||||
}
|
||||
store.set({
|
||||
phase: "available",
|
||||
update,
|
||||
version: update.version,
|
||||
notes: update.body ?? "",
|
||||
downloaded: 0,
|
||||
total: 0,
|
||||
error: "",
|
||||
});
|
||||
const target = await updateChannel();
|
||||
if (target === "direct") await checkDirect(silent);
|
||||
else if (target === "appstore") await checkAppStore(silent);
|
||||
else store.set(silent ? { phase: "idle" } : { phase: "uptodate" });
|
||||
} catch (err) {
|
||||
store.set(silent ? { phase: "idle" } : { phase: "error", error: String(err) });
|
||||
} 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() {
|
||||
const { update, phase } = useUpdater.getState();
|
||||
if (!update || phase === "downloading" || phase === "installing") return;
|
||||
|
||||
Reference in new issue
Block a user