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
|
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
|
||||||
|
|||||||
Generated
+1
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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/>
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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": {
|
"macOS": {
|
||||||
"hardenedRuntime": true
|
"hardenedRuntime": true
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"plugins": {
|
||||||
|
"appstore": {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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 & 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}>
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
Reference in new issue
Block a user