app store scripts

This commit is contained in:
pj committed 2026-09-01 22:32:34 +05:30
1 parent 585933c8a1
commit e8772e6699
6 files changed
+134 -15

No files matched your search

+30 -10
View File
@@ -1,13 +1,33 @@
Margin has no accounts, so no demo credentials are needed. Open the app and start writing.
Margin is a writing app. There are no accounts and no demo credentials are needed: open it and
start writing.
Two entitlements may look worth questioning, so here is why each is there:
com.apple.security.network.server
com.apple.security.network.server is for the Google Drive backup. Google's installed-app OAuth flow
redirects to a loopback listener on 127.0.0.1, which is the only flow Google still supports for a
desktop app, and the sandbox refuses to bind that socket without this entitlement. Nothing listens
on a routable interface and nothing accepts a connection from another machine.
This is for the optional Google Drive backup, and that is the only thing in the app that uses it.
com.apple.security.network.client is used only to reach googleapis.com for that same backup. The
backup is optional and off until the user connects their own Google account. Nothing else the app
does uses the network: writing, the page preview, spelling, grammar, and every export run entirely
on the machine.
Google's OAuth flow for installed apps returns the authorization code by redirecting the user's
browser to a loopback address. Margin binds a listener on 127.0.0.1 on an ephemeral port, opens the
consent page in the default browser, accepts the one redirect that comes back from that local
browser, reads the code and closes the socket. The App Sandbox refuses that bind without
com.apple.security.network.server, and sign-in then hangs on a browser tab with nowhere to return
to. Google withdrew the out-of-band alternative, so loopback is the only flow still available to a
desktop app.
The socket is bound to 127.0.0.1 only and never to a routable interface, so nothing off this Mac
can reach it. It exists only while a sign-in is in progress, times out after 120 seconds, and
rejects any request whose state parameter does not match the one just generated.
To see it: the cloud icon at the top of the opening library screen opens Backup and Sync, and
"Connect Google Drive" there starts the flow. Finishing it needs a Google account of your own.
Everything else in the app works without one.
com.apple.security.network.client
Used only to reach googleapis.com for that same backup, which stays off until the user connects
their own Google account. Nothing else Margin does touches the network: writing, the page preview,
spelling, grammar and both exports all run on the machine.
com.apple.security.files.user-selected.read-write
Importing an EPUB and exporting a PDF or EPUB go through the standard open and save panels, so the
app only ever reaches the file the user picked. The library itself lives in the app container.
+15 -1
View File
@@ -96,6 +96,12 @@ go through Beta App Review, which needs a contact phone number in
`appstore/metadata/review_phone.txt`; without it the script says so and carries on, because
internal testing does not need it.
`scripts/appstore-review-detail.rb` writes the store submission's App Review Information: the same
contact details and the notes in `appstore/metadata/review_notes.txt`. That is a different record
from the TestFlight one, and the two do not share anything. An explanation that only went to
TestFlight is invisible both to the reviewer looking at the store submission and to the automated
check that runs before a human sees it at all, which is how the first submission was rejected.
`scripts/appstore-compliance.rb` answers the age rating questionnaire and declares App Privacy.
Every content answer is NONE and the privacy answer is that nothing is collected, which is true:
there is no telemetry, no account and no server. The Drive backup sends bytes to the account of the
@@ -131,7 +137,15 @@ 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 and the entitlement most likely to be asked about.
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
`appstore/metadata/review_notes.txt` explains the loopback bind first and at length: that it is on
127.0.0.1 and never a routable interface, that it lives only for the duration of a sign-in, that it
times out, and how to reach the feature in the app. A rejection on this also has to be answered in
Resolution Center by hand, since that is not in the App Store Connect API.
`files.user-selected.read-write` covers EPUB import and PDF and EPUB export, all of which go
through a panel, so the app only ever reaches the one file that was pointed at. The library needs
nothing: it lives in the container.
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env ruby
# Push the App Review Information for the store submission: who to contact, and the notes that
# explain anything a reviewer would otherwise have to guess at.
#
# [email protected] ruby scripts/appstore-review-detail.rb
#
# This is a different record from the TestFlight one that testflight-setup.rb writes. Beta review
# and store review do not share notes, so an explanation that only went to TestFlight is invisible
# to the reviewer looking at the store submission, and to the automated entitlement check that runs
# before a human sees it at all.
begin
require "spaceship"
rescue LoadError
libexec = Dir["/opt/homebrew/Cellar/fastlane/*/libexec", "/usr/local/Cellar/fastlane/*/libexec"].max
abort "spaceship is not installed. `brew install fastlane` and rerun." unless libexec
ENV["GEM_PATH"] = [libexec, ENV["GEM_PATH"]].compact.join(":")
Gem.clear_paths
require "spaceship"
end
DIR = ENV.fetch("METADATA_DIR", "appstore/metadata")
BUNDLE_ID = ENV.fetch("BUNDLE_ID", "studio.margin.app")
ENV["FASTLANE_ITC_TEAM_ID"] = ENV.fetch("FASTLANE_ITC_TEAM_ID", "129377371")
NOTES_LIMIT = 4000
def field(name)
path = File.join(DIR, "#{name}.txt")
return nil unless File.exist?(path)
File.read(path).strip
end
notes = field("review_notes")
abort "#{DIR}/review_notes.txt is missing." unless notes
abort "review_notes is #{notes.length} characters, over Apple's limit of #{NOTES_LIMIT}." if notes.length > NOTES_LIMIT
phone = field("review_phone")
abort "#{DIR}/review_phone.txt is missing and Apple requires a contact number." unless phone
Spaceship::ConnectAPI.login(ENV["APPLE_EMAIL"], nil, use_portal: false, use_tunes: true)
app = Spaceship::ConnectAPI::App.find(BUNDLE_ID)
abort "No app for #{BUNDLE_ID}." unless app
puts "#{app.name} (#{app.id})"
version = app.get_edit_app_store_version(platform: Spaceship::ConnectAPI::Platform::MAC_OS)
abort "No editable macOS version; the submission may already be in review." unless version
puts " version #{version.version_string} (#{version.app_store_state})"
attributes = {
contactFirstName: field("review_first_name"),
contactLastName: field("review_last_name"),
contactEmail: field("review_email"),
contactPhone: phone,
# Margin has no accounts at all, so there is nothing for a reviewer to sign in to. Saying so
# explicitly is what stops the review coming back asking for credentials.
demoAccountRequired: false,
notes: notes,
}
detail = version.fetch_app_store_review_detail
if detail
Spaceship::ConnectAPI.patch_app_store_review_detail(
app_store_review_detail_id: detail.id,
attributes: attributes,
)
else
Spaceship::ConnectAPI.post_app_store_review_detail(
app_store_version_id: version.id,
attributes: attributes,
)
end
# Apple accepts a patch it then stores as something else often enough to be worth reading back, and
# an empty notes field is exactly the state that got this submission rejected in the first place.
written = version.fetch_app_store_review_detail&.notes.to_s
if written.empty?
abort " the notes field came back empty; nothing was saved."
elsif written != notes
puts " saved, but what came back differs from what was sent. Check it in App Store Connect."
else
puts " contact and #{notes.length} characters of review notes saved"
end
+1 -1
View File
@@ -87,7 +87,7 @@ export function BackupSettings() {
) : !connected ? (
<div className="backup-intro">
<p>
Connect Google Drive to keep a private backup of every book. margin only ever sees the files it
Connect Google Drive to keep a private backup of every project. margin only ever sees the files it
creates in a <strong>margin</strong> folder, never the rest of your Drive.
</p>
<button className="btn-primary" disabled={working} onClick={connect}>
+2 -2
View File
@@ -30,10 +30,10 @@ export async function loadBook(id: string): Promise<Book> {
try {
book = JSON.parse(contents) as Book;
} catch {
throw new Error("the book file is corrupt or unreadable");
throw new Error("the project file is corrupt or unreadable");
}
if (schemaVersion(book.schema) > SCHEMA_VERSION) {
throw new Error("this book was made with a newer version of Margin; update the app to open it");
throw new Error("this project was made with a newer version of Margin; update the app to open it");
}
return book;
}
+1 -1
View File
@@ -2,7 +2,7 @@ import { save, open } from "@tauri-apps/plugin-dialog";
import { invoke } from "@tauri-apps/api/core";
import type { Book } from "./model/book";
const FILTERS = [{ name: "margin book", extensions: ["margin"] }];
const FILTERS = [{ name: "margin project", extensions: ["margin"] }];
export async function chooseSavePath(book: Book): Promise<string | null> {
return save({ filters: FILTERS, defaultPath: `${book.metadata.title || "Untitled"}.margin` });