mirror of
https://github.com/priyanshujain/margin.git
synced 2026-10-02 11:07:04 +00:00
app store scripts
This commit is contained in:
1 parent
585933c8a1
commit
e8772e6699
6 files changed
+134
-15
No files matched your search
@@ -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
|
This is for the optional Google Drive backup, and that is the only thing in the app that uses it.
|
||||||
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.
|
|
||||||
|
|
||||||
com.apple.security.network.client is used only to reach googleapis.com for that same backup. The
|
Google's OAuth flow for installed apps returns the authorization code by redirecting the user's
|
||||||
backup is optional and off until the user connects their own Google account. Nothing else the app
|
browser to a loopback address. Margin binds a listener on 127.0.0.1 on an ephemeral port, opens the
|
||||||
does uses the network: writing, the page preview, spelling, grammar, and every export run entirely
|
consent page in the default browser, accepts the one redirect that comes back from that local
|
||||||
on the machine.
|
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
@@ -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
|
`appstore/metadata/review_phone.txt`; without it the script says so and carries on, because
|
||||||
internal testing does not need it.
|
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.
|
`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:
|
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
|
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
|
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. `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
|
`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
|
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.
|
nothing: it lives in the container.
|
||||||
|
|||||||
Executable
+85
@@ -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
|
||||||
@@ -87,7 +87,7 @@ export function BackupSettings() {
|
|||||||
) : !connected ? (
|
) : !connected ? (
|
||||||
<div className="backup-intro">
|
<div className="backup-intro">
|
||||||
<p>
|
<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.
|
creates in a <strong>margin</strong> folder, never the rest of your Drive.
|
||||||
</p>
|
</p>
|
||||||
<button className="btn-primary" disabled={working} onClick={connect}>
|
<button className="btn-primary" disabled={working} onClick={connect}>
|
||||||
|
|||||||
+2
-2
@@ -30,10 +30,10 @@ export async function loadBook(id: string): Promise<Book> {
|
|||||||
try {
|
try {
|
||||||
book = JSON.parse(contents) as Book;
|
book = JSON.parse(contents) as Book;
|
||||||
} catch {
|
} 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) {
|
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;
|
return book;
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@ import { save, open } from "@tauri-apps/plugin-dialog";
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import type { Book } from "./model/book";
|
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> {
|
export async function chooseSavePath(book: Book): Promise<string | null> {
|
||||||
return save({ filters: FILTERS, defaultPath: `${book.metadata.title || "Untitled"}.margin` });
|
return save({ filters: FILTERS, defaultPath: `${book.metadata.title || "Untitled"}.margin` });
|
||||||
|
|||||||
Reference in new issue
Block a user