diff --git a/appstore/metadata/beta_feedback_email.txt b/appstore/metadata/beta_feedback_email.txt new file mode 100644 index 0000000..4f637ec --- /dev/null +++ b/appstore/metadata/beta_feedback_email.txt @@ -0,0 +1 @@ +p@pjay.in \ No newline at end of file diff --git a/appstore/metadata/copyright.txt b/appstore/metadata/copyright.txt new file mode 100644 index 0000000..b2dcfc2 --- /dev/null +++ b/appstore/metadata/copyright.txt @@ -0,0 +1 @@ +2026 Priyanshu Jain \ No newline at end of file diff --git a/appstore/metadata/en-US/beta_description.txt b/appstore/metadata/en-US/beta_description.txt new file mode 100644 index 0000000..fd112e2 --- /dev/null +++ b/appstore/metadata/en-US/beta_description.txt @@ -0,0 +1,5 @@ +Margin is an offline writing studio. Write without anything in the way, see your words set in real typography as you go, and export a print-ready PDF or an EPUB when you are ready. + +This build runs in the App Store sandbox, which is new, so the things most worth trying are the ones that touch the file system: importing, exporting a PDF or EPUB, and the optional Google Drive backup. If any of those fail where they used to work, that is the bug worth reporting. + +There is no account and nothing to sign up for. diff --git a/appstore/metadata/en-US/description.txt b/appstore/metadata/en-US/description.txt new file mode 100644 index 0000000..5361d6e --- /dev/null +++ b/appstore/metadata/en-US/description.txt @@ -0,0 +1,13 @@ +Margin is a calm, offline writing studio. Write without anything in the way, see your words set in real typography as you go, and keep every one of them on your own machine. + +WRITE +A quiet editor that stays out of the way, with your work down one side and a live page preview down the other, showing your words as they will actually appear rather than as a word processor imagines them. + +PROOF +Spelling and grammar are checked as you write, using the same system engine as the rest of macOS. Your words are never sent anywhere to be checked. + +PUBLISH +When you are ready, export a print-ready PDF set in real typography, with proper margins, page numbers and running heads. Or export EPUB, ready for Apple Books, Kindle, Kobo and the rest. + +YOURS +No account. No sign-up. No subscription. Your work is a single file on your computer that you can copy, rename, back up, or move to another machine. Nothing is locked inside a library you cannot get out of. If you would like a backup, Margin can save one to your own Google Drive, in your account and under your control. diff --git a/appstore/metadata/en-US/keywords.txt b/appstore/metadata/en-US/keywords.txt new file mode 100644 index 0000000..f5eb97e --- /dev/null +++ b/appstore/metadata/en-US/keywords.txt @@ -0,0 +1 @@ +writing,writer,editor,text,document,offline,grammar,typography,epub,pdf,author,manuscript,draft \ No newline at end of file diff --git a/appstore/metadata/en-US/marketing_url.txt b/appstore/metadata/en-US/marketing_url.txt new file mode 100644 index 0000000..1b2c467 --- /dev/null +++ b/appstore/metadata/en-US/marketing_url.txt @@ -0,0 +1 @@ +https://margin.73ai.org \ No newline at end of file diff --git a/appstore/metadata/en-US/name.txt b/appstore/metadata/en-US/name.txt new file mode 100644 index 0000000..48aaddf --- /dev/null +++ b/appstore/metadata/en-US/name.txt @@ -0,0 +1 @@ +Margin: The Writing App \ No newline at end of file diff --git a/appstore/metadata/en-US/privacy_url.txt b/appstore/metadata/en-US/privacy_url.txt new file mode 100644 index 0000000..b79ec69 --- /dev/null +++ b/appstore/metadata/en-US/privacy_url.txt @@ -0,0 +1 @@ +https://margin.73ai.org/privacy \ No newline at end of file diff --git a/appstore/metadata/en-US/promotional_text.txt b/appstore/metadata/en-US/promotional_text.txt new file mode 100644 index 0000000..9bde454 --- /dev/null +++ b/appstore/metadata/en-US/promotional_text.txt @@ -0,0 +1 @@ +Write without anything in the way. Real typography, spelling and grammar on your own machine, and export to PDF or EPUB when you are ready. \ No newline at end of file diff --git a/appstore/metadata/en-US/release_notes.txt b/appstore/metadata/en-US/release_notes.txt new file mode 100644 index 0000000..09de061 --- /dev/null +++ b/appstore/metadata/en-US/release_notes.txt @@ -0,0 +1 @@ +First release. \ No newline at end of file diff --git a/appstore/metadata/en-US/subtitle.txt b/appstore/metadata/en-US/subtitle.txt new file mode 100644 index 0000000..0b2649c --- /dev/null +++ b/appstore/metadata/en-US/subtitle.txt @@ -0,0 +1 @@ +A calm, offline writing studio \ No newline at end of file diff --git a/appstore/metadata/en-US/support_url.txt b/appstore/metadata/en-US/support_url.txt new file mode 100644 index 0000000..1b2c467 --- /dev/null +++ b/appstore/metadata/en-US/support_url.txt @@ -0,0 +1 @@ +https://margin.73ai.org \ No newline at end of file diff --git a/appstore/metadata/primary_category.txt b/appstore/metadata/primary_category.txt new file mode 100644 index 0000000..a0b2696 --- /dev/null +++ b/appstore/metadata/primary_category.txt @@ -0,0 +1 @@ +PRODUCTIVITY \ No newline at end of file diff --git a/appstore/metadata/review_first_name.txt b/appstore/metadata/review_first_name.txt new file mode 100644 index 0000000..3969105 --- /dev/null +++ b/appstore/metadata/review_first_name.txt @@ -0,0 +1 @@ +Priyanshu \ No newline at end of file diff --git a/appstore/metadata/review_last_name.txt b/appstore/metadata/review_last_name.txt new file mode 100644 index 0000000..f3cbe6b --- /dev/null +++ b/appstore/metadata/review_last_name.txt @@ -0,0 +1 @@ +Jain \ No newline at end of file diff --git a/appstore/metadata/review_notes.txt b/appstore/metadata/review_notes.txt new file mode 100644 index 0000000..02205bc --- /dev/null +++ b/appstore/metadata/review_notes.txt @@ -0,0 +1,13 @@ +Margin has no accounts, so no demo credentials are needed. Open the app and start writing. + +Two entitlements may look worth questioning, so here is why each is there: + +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. + +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. diff --git a/scripts/apple-provision.rb b/scripts/apple-provision.rb new file mode 100755 index 0000000..2416f85 --- /dev/null +++ b/scripts/apple-provision.rb @@ -0,0 +1,189 @@ +#!/usr/bin/env ruby +# Create the Apple Developer resources a release needs, and turn them into files CI can use. +# +# Everything here is find-or-create, so running it twice does nothing the second time. That matters +# most for the Developer ID certificate: an account may hold only a handful, they cannot be +# un-revoked, and every copy of the app already signed by one stops verifying if it goes away. +# +# The private keys are never sent to Apple and never leave ~/.margin-signing. Apple only ever sees +# the certificate signing requests, which is the whole point of generating them with openssl up +# front rather than letting a tool make its own. +# +# BUNDLE_ID=studio.margin.app APP_NAME=Margin ruby scripts/apple-provision.rb +# +# Run it yourself rather than through an agent: the Apple ID password and the two-factor code are +# prompted for on the terminal. +begin + require "spaceship" +rescue LoadError + # Homebrew vendors fastlane's gems under libexec instead of putting them on the default gem + # path, so spaceship is not requirable until that directory is added to it. + 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 + +require "openssl" +require "fileutils" +require "securerandom" +require "net/http" +require "tmpdir" + +# Listing profiles otherwise goes through developerservices2.apple.com, Apple's Xcode-only +# endpoint, which rejects a plain spaceship session with "Please update to Xcode 7.3 or later" +# no matter how current Xcode actually is. This routes it back to the ordinary portal API. +ENV["SPACESHIP_AVOID_XCODE_API"] = "1" + +DIR = File.expand_path("~/.margin-signing") +BUNDLE_ID = ENV.fetch("BUNDLE_ID", "studio.margin.app") +APP_NAME = ENV.fetch("APP_NAME", "Margin") +EMAIL = ENV["APPLE_EMAIL"] + +# Apple's intermediates. codesign builds a chain from the leaf up, so a .p12 holding only the leaf +# and its key fails on a fresh CI keychain with "unable to build chain to self-signed root". +INTERMEDIATES = { + "AppleWWDRCAG3" => "https://www.apple.com/certificateauthority/AppleWWDRCAG3.cer", + "DeveloperIDG2CA" => "https://www.apple.com/certificateauthority/DeveloperIDG2CA.cer", +} + +CERTS = [ + { key: "developer-id", klass: Spaceship::Portal::Certificate::DeveloperIdApplication, + label: "Developer ID Application (direct download, notarized)", ca: "DeveloperIDG2CA" }, + { key: "apple-distribution", klass: Spaceship::Portal::Certificate::MacAppDistribution, + label: "Mac App Distribution (App Store .app)", ca: "AppleWWDRCAG3" }, + { key: "mac-installer", klass: Spaceship::Portal::Certificate::MacInstallerDistribution, + label: "Mac Installer Distribution (App Store .pkg)", ca: "AppleWWDRCAG3" }, +] + +def common_name(cert) + cert.subject.to_a.find { |n, _, _| n == "CN" }&.at(1) +end + +def fetch_intermediate(name) + path = File.join(DIR, "#{name}.cer") + unless File.exist?(path) + uri = URI(INTERMEDIATES.fetch(name)) + File.binwrite(path, Net::HTTP.get(uri)) + end + OpenSSL::X509::Certificate.new(File.binread(path)) +end + +# Confirm macOS can actually read the bundle, because the failure mode otherwise shows up days +# later inside a CI keychain as "wrong password" rather than as anything about the format. +def importable?(p12_path, password) + keychain = File.join(Dir.tmpdir, "margin-p12-check-#{SecureRandom.hex(4)}.keychain-db") + system("security", "create-keychain", "-p", "check", keychain, out: File::NULL, err: File::NULL) + system("security", "unlock-keychain", "-p", "check", keychain, out: File::NULL, err: File::NULL) + ok = system("security", "import", p12_path, "-k", keychain, "-P", password, + out: File::NULL, err: File::NULL) + system("security", "delete-keychain", keychain, out: File::NULL, err: File::NULL) + ok +end + +# Returns nil on success, or a sentence saying what went wrong. +def write_p12(spec, cert) + key_path = File.join(DIR, "#{spec[:key]}.key") + unless cert.check_private_key(OpenSSL::PKey::RSA.new(File.read(key_path))) + return "the issued certificate does not match the local private key, so Apple issued it " \ + "against a different CSR. Revoke it in the portal and rerun." + end + + password = SecureRandom.hex(24) + p12_path = File.join(DIR, "#{spec[:key]}.p12") + + built = Dir.mktmpdir do |tmp| + leaf = File.join(tmp, "leaf.pem") + ca = File.join(tmp, "ca.pem") + File.write(leaf, cert.to_pem) + File.write(ca, fetch_intermediate(spec[:ca]).to_pem) + + # Ruby links OpenSSL 3, whose PKCS12 default MAC is SHA-256. Apple's Security framework reads + # only the legacy SHA-1 MAC and reports the mismatch as a wrong password, so the bundle has to + # come from the LibreSSL at /usr/bin/openssl, which still writes the older format. The password + # goes through the environment rather than argv so it stays out of the process list. + system({ "P12PASS" => password }, "/usr/bin/openssl", "pkcs12", "-export", + "-inkey", key_path, "-in", leaf, "-certfile", ca, + "-name", common_name(cert), "-passout", "env:P12PASS", "-out", p12_path, + out: File::NULL, err: File::NULL) + end + + return "/usr/bin/openssl could not build the bundle." unless built + return "macOS refused to import the bundle that was just built." unless importable?(p12_path, password) + + File.write(File.join(DIR, "#{spec[:key]}.p12.pass"), password) + File.chmod(0o600, p12_path, File.join(DIR, "#{spec[:key]}.p12.pass")) + nil +end + +FileUtils.mkdir_p(DIR) +Spaceship::Portal.login(EMAIL) +Spaceship::Portal.select_team +team_id = Spaceship::Portal.client.team_id +puts "Team ID: #{team_id}" +puts + +identities = {} + +CERTS.each do |spec| + existing = spec[:klass].all.select { |c| c.status == "Issued" } + cert_obj = existing.first + + if cert_obj + puts "#{spec[:label]}: already exists (#{cert_obj.id}), not creating another." + else + csr = File.read(File.join(DIR, "#{spec[:key]}.csr")) + cert_obj = spec[:klass].create!(csr: csr) + puts "#{spec[:label]}: created (#{cert_obj.id})." + end + + x509 = cert_obj.download + File.binwrite(File.join(DIR, "#{spec[:key]}.cer"), x509.to_der) + + identities[spec[:key]] = common_name(x509) + puts " identity: #{common_name(x509)}" + + problem = write_p12(spec, x509) + puts(problem ? " no p12: #{problem}" : " wrote #{spec[:key]}.p12") + puts +end + +app = Spaceship::Portal::App.find(BUNDLE_ID, mac: true) +if app + puts "App ID #{BUNDLE_ID}: already registered." +else + app = Spaceship::Portal::App.create!(bundle_id: BUNDLE_ID, name: APP_NAME, mac: true) + puts "App ID #{BUNDLE_ID}: registered." +end + +profile_name = "#{APP_NAME} App Store" +profile = Spaceship::Portal::ProvisioningProfile::AppStore.all(mac: true).find do |p| + p.app.bundle_id == BUNDLE_ID && p.status == "Active" +end + +if profile + puts "Provisioning profile: reusing #{profile.name}." +else + mas_cert = Spaceship::Portal::Certificate::MacAppDistribution.all.first + profile = Spaceship::Portal::ProvisioningProfile::AppStore.create!( + name: profile_name, bundle_id: BUNDLE_ID, certificate: mas_cert, mac: true + ) + puts "Provisioning profile: created #{profile.name}." +end + +profile_path = File.join(DIR, "#{BUNDLE_ID}.provisionprofile") +File.binwrite(profile_path, profile.download) +File.chmod(0o600, profile_path) + +File.write(File.join(DIR, "#{BUNDLE_ID}.env"), <<~ENV) + APPLE_TEAM_ID="#{team_id}" + APPLE_SIGNING_IDENTITY="#{identities['developer-id']}" + MAS_APP_IDENTITY="#{identities['apple-distribution']}" + MAS_INSTALLER_IDENTITY="#{identities['mac-installer']}" +ENV + +puts +puts "Wrote #{profile_path} and #{BUNDLE_ID}.env into #{DIR}." +puts "Still to do by hand, because Apple has no API for it: create an App Store Connect API key" +puts "(Users and Access, Integrations) and save the .p8 as #{DIR}/AuthKey.p8." diff --git a/scripts/apple-secrets.sh b/scripts/apple-secrets.sh new file mode 100755 index 0000000..8ee2562 --- /dev/null +++ b/scripts/apple-secrets.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# Push everything apple-provision.rb produced into this repo's GitHub Actions secrets. +# +# Values are piped from the files straight into `gh`, never echoed, so running this in a shared +# terminal or through an agent does not leak a signing key into the scrollback. +set -euo pipefail + +DIR="${MARGIN_SIGNING_DIR:-$HOME/.margin-signing}" +BUNDLE_ID="${BUNDLE_ID:-studio.margin.app}" +REPO="${REPO:-priyanshujain/margin}" +ENV_FILE="$DIR/$BUNDLE_ID.env" + +[ -f "$ENV_FILE" ] || { echo "apple-secrets: $ENV_FILE is missing; run apple-provision.rb first." >&2; exit 1; } +# shellcheck source=/dev/null +set -a; . "$ENV_FILE"; set +a + +set_plain() { + printf '%s' "$2" | gh secret set "$1" --repo "$REPO" --body - + echo " set $1" +} + +set_b64() { + [ -f "$2" ] || { echo " skipped $1 ($2 is missing)"; return; } + base64 -i "$2" | tr -d '\n' | gh secret set "$1" --repo "$REPO" --body - + echo " set $1" +} + +set_file() { + [ -f "$2" ] || { echo " skipped $1 ($2 is missing)"; return; } + gh secret set "$1" --repo "$REPO" < "$2" + echo " set $1" +} + +echo "Signing identities and team:" +set_plain APPLE_TEAM_ID "$APPLE_TEAM_ID" +set_plain APPLE_SIGNING_IDENTITY "$APPLE_SIGNING_IDENTITY" +set_plain MAS_APP_IDENTITY "$MAS_APP_IDENTITY" +set_plain MAS_INSTALLER_IDENTITY "$MAS_INSTALLER_IDENTITY" + +echo "Certificates:" +set_b64 APPLE_CERTIFICATE "$DIR/developer-id.p12" +set_file APPLE_CERTIFICATE_PASSWORD "$DIR/developer-id.p12.pass" +set_b64 MAS_APP_CERTIFICATE "$DIR/apple-distribution.p12" +set_file MAS_APP_CERTIFICATE_PASSWORD "$DIR/apple-distribution.p12.pass" +set_b64 MAS_INSTALLER_CERTIFICATE "$DIR/mac-installer.p12" +set_file MAS_INSTALLER_CERTIFICATE_PASSWORD "$DIR/mac-installer.p12.pass" +set_b64 MAS_PROVISION_PROFILE "$DIR/$BUNDLE_ID.provisionprofile" + +echo "App Store Connect API key:" +if [ -f "$DIR/AuthKey.p8" ] && [ -f "$DIR/AuthKey.env" ]; then + # shellcheck source=/dev/null + set -a; . "$DIR/AuthKey.env"; set +a + # An empty value here would be accepted by `gh` and then fail notarization as an auth error that + # says nothing about a missing issuer, so refuse it at the point the mistake is still visible. + if [ -z "${APPLE_API_KEY_ID:-}" ] || [ -z "${APPLE_API_ISSUER:-}" ]; then + echo " refused: AuthKey.env is missing APPLE_API_KEY_ID or APPLE_API_ISSUER." >&2 + exit 1 + fi + set_plain APPLE_API_KEY_ID "$APPLE_API_KEY_ID" + set_plain APPLE_API_ISSUER "$APPLE_API_ISSUER" + set_b64 APPLE_API_KEY_P8 "$DIR/AuthKey.p8" +else + echo " skipped: put the .p8 at $DIR/AuthKey.p8 and write $DIR/AuthKey.env with" + echo " APPLE_API_KEY_ID= and APPLE_API_ISSUER=, then rerun." +fi + +echo +echo "Not handled here: HOMEBREW_TAP_DEPLOY_KEY, an SSH deploy key on the tap rather than" +echo "anything Apple issued." diff --git a/scripts/appstore-compliance.rb b/scripts/appstore-compliance.rb new file mode 100644 index 0000000..27c31a2 --- /dev/null +++ b/scripts/appstore-compliance.rb @@ -0,0 +1,95 @@ +#!/usr/bin/env ruby +# Declare the age rating and set pricing. These are the two things App Store Connect will not let a +# submission through without, and neither has anything to do with the copy in appstore-listing.rb. +# +# APPLE_EMAIL=you@example.com ruby scripts/appstore-compliance.rb +# +# Every content answer here is NONE because Margin is an editor for words the person using it wrote +# themselves. It ships no media, has no feed, no other users, and no in-app browser. If any of that +# ever stops being true, this file is the thing that has to change with it. +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 + +BUNDLE_ID = ENV.fetch("BUNDLE_ID", "studio.margin.app") +ENV["FASTLANE_ITC_TEAM_ID"] = ENV.fetch("FASTLANE_ITC_TEAM_ID", "129377371") + +NONE = Spaceship::ConnectAPI::AgeRatingDeclaration::Rating::NONE + +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})" + +info = app.fetch_edit_app_info +abort "No editable app info." unless info + +declaration = info.fetch_age_rating_declaration +abort "No age rating declaration to write to." unless declaration + +graded = { + alcoholTobaccoOrDrugUseOrReferences: NONE, + contests: NONE, + gamblingSimulated: NONE, + gunsOrOtherWeapons: NONE, + horrorOrFearThemes: NONE, + matureOrSuggestiveThemes: NONE, + medicalOrTreatmentInformation: NONE, + profanityOrCrudeHumor: NONE, + sexualContentGraphicAndNudity: NONE, + sexualContentOrNudity: NONE, + violenceCartoonOrFantasy: NONE, + violenceRealistic: NONE, + violenceRealisticProlongedGraphicOrSadistic: NONE, +} + +# The app opens links in the system browser rather than rendering the web itself, and the only +# content it ever shows is the person's own writing, so there is no unrestricted web access and no +# user generated content in the sense Apple means: content from other people. +boolean = { + advertising: false, + ageAssurance: false, + gambling: false, + healthOrWellnessTopics: false, + lootBox: false, + messagingAndChat: false, + parentalControls: false, + unrestrictedWebAccess: false, + userGeneratedContent: false, +} + +declaration.update(attributes: graded.merge(boolean)) +puts " age rating declared, every content question answered NONE" + +# App Privacy. Margin has no telemetry, no accounts and no server of its own, so nothing is +# collected. The Google Drive backup is the one thing that sends bytes anywhere, and it sends them +# to the account of the person who turned it on, which is not the developer collecting anything. +usages = Spaceship::ConnectAPI::AppDataUsage.all( + app_id: app.id, includes: "category,grouping,purpose,dataProtection" +) + +if usages.any?(&:is_not_collected?) + puts " privacy already declared as data not collected" +else + Spaceship::ConnectAPI::AppDataUsage.create(app_id: app.id, app_data_usage_protection_id: "DATA_NOT_COLLECTED") + puts " privacy declared: data not collected" +end + +state = Spaceship::ConnectAPI::AppDataUsagesPublishState.get(app_id: app.id) +if state.published + puts " privacy declaration already published" +else + state.publish! + puts " privacy declaration published" +end + +# Free, everywhere. Territory availability is left alone: the default is all of them. +app.update(attributes: { pricing: [] }) if ENV["SET_PRICING"] +puts " pricing left as configured (the app is free, which is the default for a new record)" diff --git a/scripts/appstore-listing.rb b/scripts/appstore-listing.rb new file mode 100755 index 0000000..a400e98 --- /dev/null +++ b/scripts/appstore-listing.rb @@ -0,0 +1,122 @@ +#!/usr/bin/env ruby +# Push the listing copy in appstore/metadata to App Store Connect. +# +# The copy lives in text files rather than in here so that changing a description is a diff someone +# can read, and so the store listing is reviewable in the same place as the code it describes. +# +# APPLE_EMAIL=you@example.com ruby scripts/appstore-listing.rb +# +# Run it yourself: the Apple ID login prompts for a two-factor code the first time each month. +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 + +require "json" + +DIR = ENV.fetch("METADATA_DIR", "appstore/metadata") +LOCALE = "en-US" +BUNDLE_ID = ENV.fetch("BUNDLE_ID", "studio.margin.app") + +# This Apple ID can see more than one App Store Connect team, and the wrong one belongs to someone +# else entirely. Pin it rather than letting spaceship pick. +ITC_TEAM_ID = ENV.fetch("FASTLANE_ITC_TEAM_ID", "129377371") +ENV["FASTLANE_ITC_TEAM_ID"] = ITC_TEAM_ID + +# Apple rejects an over-length field with a validation error that does not name the limit, so the +# check belongs here where the number is visible. +LIMITS = { + "name" => 30, "subtitle" => 30, "keywords" => 100, + "promotional_text" => 170, "description" => 4000, +}.freeze + +def field(name, localized: true) + path = localized ? File.join(DIR, LOCALE, "#{name}.txt") : File.join(DIR, "#{name}.txt") + return nil unless File.exist?(path) + + value = File.read(path).strip + limit = LIMITS[name] + abort "#{name} is #{value.length} characters, over Apple's limit of #{limit}." if limit && value.length > limit + value +end + +Spaceship::ConnectAPI.login(ENV["APPLE_EMAIL"], nil, use_portal: false, use_tunes: true) + +app = Spaceship::ConnectAPI::App.find(BUNDLE_ID) +abort "No app on team #{ITC_TEAM_ID} for #{BUNDLE_ID}. Create it with produce first." unless app +puts "#{app.name} (#{app.id})" + +info = app.fetch_edit_app_info +abort "No editable app info; the listing may already be in review." unless info + +localization = info.get_app_info_localizations.find { |l| l.locale == LOCALE } +localization ||= info.create_app_info_localization(attributes: { locale: LOCALE }) +# The name is the one field a person is likely to change by hand in App Store Connect, and losing +# somebody's naming decision to a stale text file is not a good trade. So divergence is reported +# and the live value kept, rather than overwritten. +attributes = { subtitle: field("subtitle"), privacyPolicyUrl: field("privacy_url") } +wanted_name = field("name") +if wanted_name && localization.name && wanted_name != localization.name + puts " keeping the name set in App Store Connect (#{localization.name.inspect});" + puts " #{DIR}/#{LOCALE}/name.txt says #{wanted_name.inspect}. Update the file to match, or pass" + puts " FORCE_NAME=1 to make the file win." + attributes[:name] = wanted_name if ENV["FORCE_NAME"] +else + attributes[:name] = wanted_name +end + +localization.update(attributes: attributes) +puts " subtitle and privacy policy set" + +category = field("primary_category", localized: false) +if category + info.update_categories(category_id_map: { primary_category_id: category }) + puts " primary category set to #{category}" +end + +version = app.get_edit_app_store_version(platform: Spaceship::ConnectAPI::Platform::MAC_OS) +abort "No editable macOS version to write to." unless version + +# The store version has to match the CFBundleShortVersionString of the build that will be uploaded +# against it, and that comes from tauri.conf.json like every other version in the repo. Left alone, +# a record created by `produce` sits at 1.0 and rejects the first build with a version mismatch. +app_version = JSON.parse(File.read("src-tauri/tauri.conf.json"))["version"] +if version.version_string != app_version + version.update(attributes: { versionString: app_version }) + puts " version corrected from #{version.version_string} to #{app_version}" + version = app.get_edit_app_store_version(platform: Spaceship::ConnectAPI::Platform::MAC_OS) +end + +version_localization = version.get_app_store_version_localizations.find { |l| l.locale == LOCALE } +version_localization ||= version.create_app_store_version_localization(attributes: { locale: LOCALE }) +attributes = { + description: field("description"), + keywords: field("keywords"), + promotionalText: field("promotional_text"), + supportUrl: field("support_url"), + marketingUrl: field("marketing_url"), +} + +# Release notes describe what changed since the last release, so Apple refuses them on a first +# version and there is nothing truthful to put there anyway. +if app.get_live_app_store_version(platform: Spaceship::ConnectAPI::Platform::MAC_OS) + attributes[:whatsNew] = field("release_notes") +else + puts " skipping release notes: nothing has shipped yet for them to be relative to" +end + +version_localization.update(attributes: attributes) +puts " description, keywords and links set on version #{version.version_string}" + +copyright = field("copyright", localized: false) +version.update(attributes: { copyright: copyright }) if copyright +puts " copyright set to #{copyright}" if copyright + +puts +puts "Screenshots are not set here. They are required to submit for review, but not to upload a" +puts "build or to run either tier of TestFlight." diff --git a/scripts/appstore-screenshots.rb b/scripts/appstore-screenshots.rb new file mode 100755 index 0000000..e50308a --- /dev/null +++ b/scripts/appstore-screenshots.rb @@ -0,0 +1,71 @@ +#!/usr/bin/env ruby +# Upload the rendered frames in appstore/screenshots to the App Store listing. +# +# APPLE_EMAIL=you@example.com ruby scripts/appstore-screenshots.rb +# +# The frames are rendered by the HTML under appstore/screenshots/src, so this only ever moves +# finished PNGs. Rerunning replaces what is there rather than appending, because a listing that +# quietly accumulated ten frames across five runs would be worse than one that is simply current. +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 + +require "shellwords" + +DIR = ENV.fetch("SCREENSHOT_DIR", "appstore/screenshots") +LOCALE = "en-US" +BUNDLE_ID = ENV.fetch("BUNDLE_ID", "studio.margin.app") +ENV["FASTLANE_ITC_TEAM_ID"] = ENV.fetch("FASTLANE_ITC_TEAM_ID", "129377371") + +# The only size App Store Connect takes for a Mac app, out of the four it documents, that this +# pipeline renders. Anything else is a mistake worth stopping for. +EXPECTED = [2560, 1600].freeze + +frames = Dir[File.join(DIR, "frame-*.png")].sort +abort "No frames in #{DIR}." if frames.empty? + +frames.each do |path| + dimensions = `sips -g pixelWidth -g pixelHeight #{path.shellescape} 2>/dev/null` + .scan(/pixel(?:Width|Height):\s*(\d+)/).flatten.map(&:to_i) + next if dimensions == EXPECTED + + abort "#{path} is #{dimensions.join('x')}, and Apple wants #{EXPECTED.join('x')}." +end +puts "#{frames.size} frames, all #{EXPECTED.join('x')}" + +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." unless version + +localization = version.get_app_store_version_localizations.find { |l| l.locale == LOCALE } +abort "No #{LOCALE} localization; run appstore-listing.rb first." unless localization + +display_type = Spaceship::ConnectAPI::AppScreenshotSet::DisplayType::APP_DESKTOP +set = localization.get_app_screenshot_sets.find { |s| s.screenshot_display_type == display_type } + +if set + set.app_screenshots.each(&:delete!) + puts " cleared #{set.app_screenshots.size} existing screenshots" +else + set = localization.create_app_screenshot_set(attributes: { screenshotDisplayType: display_type }) + puts " created the desktop screenshot set" +end + +frames.each_with_index do |path, index| + set.upload_screenshot(path: path, position: index) + puts " uploaded #{File.basename(path)}" +end + +puts +puts "Apple processes each image before it counts as attached; give it a minute before checking." diff --git a/scripts/testflight-release.rb b/scripts/testflight-release.rb new file mode 100755 index 0000000..a9ef355 --- /dev/null +++ b/scripts/testflight-release.rb @@ -0,0 +1,95 @@ +#!/usr/bin/env ruby +# Put the most recently uploaded build in front of testers. +# +# Apple takes somewhere between a few minutes and an hour to process an upload, and nothing can be +# assigned until it has. So this waits rather than failing, and says what it is waiting for. +# +# APPLE_EMAIL=you@example.com ruby scripts/testflight-release.rb +# +# Internal testers get the build as soon as it is assigned. External testers are gated on Beta App +# Review, which this submits for and which usually comes back within a day. +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 + +BUNDLE_ID = ENV.fetch("BUNDLE_ID", "studio.margin.app") +ENV["FASTLANE_ITC_TEAM_ID"] = ENV.fetch("FASTLANE_ITC_TEAM_ID", "129377371") +WAIT_SECONDS = Integer(ENV.fetch("WAIT_SECONDS", "1800")) + +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})" + +deadline = Time.now + WAIT_SECONDS +build = nil + +loop do + builds = Spaceship::ConnectAPI::Build.all(app_id: app.id, sort: "-uploadedDate", limit: 5) + ready = builds.reject(&:expired).find { |b| b.processing_state == "VALID" } + + if ready + build = ready + break + end + + pending = builds.find { |b| b.processing_state == "PROCESSING" } + if Time.now > deadline + abort "Timed out after #{WAIT_SECONDS}s. #{pending ? 'The build is still processing.' : 'No build has appeared yet.'}" + end + + puts(pending ? " waiting: build #{pending.version} is still processing" : " waiting: no build has appeared yet") + sleep(30) +end + +puts " build #{build.app_version} (#{build.version}) is ready" + +groups = app.get_beta_groups + +# A group created with hasAccessToAllBuilds receives every build the moment it processes, and Apple +# rejects an explicit assignment to one rather than treating it as a no-op. The internal group is +# exactly that, so there is nothing to do for it and nothing to report either. +assignable = groups.reject { |g| g.is_internal_group || g.has_access_to_all_builds } +already = (build.get_beta_groups.map(&:id) rescue []) +to_add = assignable.reject { |g| already.include?(g.id) } + +if to_add.empty? + puts " nothing to assign: #{groups.map(&:name).join(', ')} already have this build" +else + build.add_beta_groups(beta_groups: to_add) + puts " assigned to #{to_add.map(&:name).join(', ')}" +end + +# Only external groups are gated on review, so an app with internal testers only never needs this. +if groups.any? { |g| !g.is_internal_group } + begin + Spaceship::ConnectAPI.post_beta_app_review_submissions(build_id: build.id) + puts " submitted for beta app review, which gates the external testers" + rescue => e + # Resubmitting an already submitted build is not an error worth failing the run over. + puts " beta app review not submitted: #{e.message.lines.first.to_s.strip}" + end +end + +# The store version needs the build attached too, and that is a separate thing from TestFlight. +# Until it is, App Store Connect shows the listing with no app icon, because for a Mac app the icon +# is read out of the attached build rather than uploaded alongside the other artwork. +version = app.get_edit_app_store_version(platform: Spaceship::ConnectAPI::Platform::MAC_OS) +if version.nil? + puts " no editable store version to attach the build to" +elsif version.build&.id == build.id + puts " already attached to store version #{version.version_string}" +else + version.select_build(build_id: build.id) + puts " attached to store version #{version.version_string}, which is what surfaces the app icon" +end + +puts +puts "Internal testers can install now. External testers wait on beta app review." diff --git a/scripts/testflight-setup.rb b/scripts/testflight-setup.rb new file mode 100644 index 0000000..873ea2a --- /dev/null +++ b/scripts/testflight-setup.rb @@ -0,0 +1,105 @@ +#!/usr/bin/env ruby +# Set up TestFlight for the app: the tester-facing blurb, the details Beta App Review asks for, and +# the two groups. None of this needs a build to exist, so it can all be in place before the first +# upload and the build then only has to be assigned to a group. +# +# APPLE_EMAIL=you@example.com ruby scripts/testflight-setup.rb +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") +LOCALE = "en-US" +BUNDLE_ID = ENV.fetch("BUNDLE_ID", "studio.margin.app") +ENV["FASTLANE_ITC_TEAM_ID"] = ENV.fetch("FASTLANE_ITC_TEAM_ID", "129377371") + +def field(name, localized: true) + path = localized ? File.join(DIR, LOCALE, "#{name}.txt") : File.join(DIR, "#{name}.txt") + File.exist?(path) ? File.read(path).strip : nil +end + +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})" + +localization = app.get_beta_app_localizations.find { |l| l.locale == LOCALE } +attributes = { + description: field("beta_description"), + feedbackEmail: field("beta_feedback_email", localized: false), + marketingUrl: field("marketing_url"), + privacyPolicyUrl: field("privacy_url"), +} + +if localization + Spaceship::ConnectAPI.patch_beta_app_localizations(localization_id: localization.id, attributes: attributes) +else + Spaceship::ConnectAPI.post_beta_app_localizations(app_id: app.id, attributes: attributes.merge(locale: LOCALE)) +end +puts " tester blurb and feedback address set" + +# Apple requires a contact phone number here, and this only gates external testing: internal +# testers never go through Beta App Review. So a missing number is a warning, not a failure. +if field("review_phone", localized: false) + Spaceship::ConnectAPI.patch_beta_app_review_detail(app_id: app.id, attributes: { + contactFirstName: field("review_first_name", localized: false), + contactLastName: field("review_last_name", localized: false), + contactEmail: field("review_email", localized: false), + contactPhone: field("review_phone", localized: false), + # 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: field("review_notes", localized: false), + }) + puts " beta app review contact and notes set" +else + puts " skipping beta app review details: #{DIR}/review_phone.txt is missing and Apple requires" + puts " a contact number. Internal testing works without it; external testing does not." +end + +existing = app.get_beta_groups.map(&:name) + +[ + { name: "Internal", internal: true, public_link: false }, + { name: "Public Beta", internal: false, public_link: true }, +].each do |group| + if existing.include?(group[:name]) + puts " group #{group[:name].inspect} already exists" + next + end + + if group[:internal] + # spaceship always sends the public-link attributes, and App Store Connect rejects them + # outright on an internal group rather than ignoring them, so this one is posted by hand. + body = { + data: { + attributes: { name: group[:name], isInternalGroup: true, hasAccessToAllBuilds: true }, + relationships: { app: { data: { id: app.id, type: "apps" } } }, + type: "betaGroups", + }, + } + Spaceship::ConnectAPI.client.test_flight_request_client.post("v1/betaGroups", body) + puts " created internal group #{group[:name].inspect}" + else + created = app.create_beta_group( + group_name: group[:name], + is_internal_group: false, + public_link_enabled: true, + public_link_limit_enabled: true, + ) + puts " created external group #{created.name.inspect}" + end +end + +puts +app.get_beta_groups.each do |g| + kind = g.is_internal_group ? "internal" : "external" + puts " #{g.name} (#{kind})#{g.public_link ? " #{g.public_link}" : ''}" +end