diff --git a/appstore/metadata/review_notes.txt b/appstore/metadata/review_notes.txt index 02205bc..7a494be 100644 --- a/appstore/metadata/review_notes.txt +++ b/appstore/metadata/review_notes.txt @@ -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. diff --git a/docs/publishing.md b/docs/publishing.md index 22ed2d6..2766be2 100644 --- a/docs/publishing.md +++ b/docs/publishing.md @@ -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. diff --git a/scripts/appstore-review-detail.rb b/scripts/appstore-review-detail.rb new file mode 100755 index 0000000..bb1a028 --- /dev/null +++ b/scripts/appstore-review-detail.rb @@ -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. +# +# APPLE_EMAIL=you@example.com 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 diff --git a/src/components/BackupSettings.tsx b/src/components/BackupSettings.tsx index dc19520..0b1feeb 100644 --- a/src/components/BackupSettings.tsx +++ b/src/components/BackupSettings.tsx @@ -87,7 +87,7 @@ export function BackupSettings() { ) : !connected ? (
- 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 margin folder, never the rest of your Drive.