diff --git a/.github/workflows/build_test.yml b/.github/workflows/build_test.yml index d7a327f0..de20f83c 100644 --- a/.github/workflows/build_test.yml +++ b/.github/workflows/build_test.yml @@ -45,9 +45,7 @@ jobs: - name: run tests run: bundle exec fastlane tests - # taking the pictures is a quarter hour of simulators and belongs to the - # release, but a screenshot target that no longer compiles should not wait - # for one to say so + # Compile screenshot tests here; capture them during release. - name: build the screenshot tests run: > xcodebuild build-for-testing @@ -62,9 +60,7 @@ jobs: path: | /Users/runner/Library/Developer/Xcode/DerivedData/OpenDocumentReader-*/ - # the test job only builds the simulator slice of one target, so release-only - # and device-only breakage used to surface at deploy time. Both schemes, since - # the flavors link different sources and different sdks + # Build both device targets to check Release code and each SDK configuration. build: runs-on: macos-26 strategy: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 45f885ec..5c180586 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -185,13 +185,7 @@ jobs: build/*.app.dSYM.zip if-no-files-found: warn - # Beside the build rather than behind it: it signs nothing and uploads nothing, - # it just drives simulators, and it takes about as long. On a dry run too - - # the artifact is the only way to look at the pictures before the store does. - # A runner per device rather than one doing both. Two simulators on the seven - # gigabytes a runner has spent their time swapping, and the runs took five - # times as long as they do alone; a device to a machine is that, structurally. - # It halves the wall clock as well, since the two halves photograph at once. + # Capture screenshots alongside the build, one runner per device to limit memory use. screenshots: if: ${{ inputs.screenshots_from_run == '' }} runs-on: macos-26 @@ -221,10 +215,7 @@ jobs: python-version: "3.13" - run: python3 -m pip install --quiet Pillow - # The lane builds once into build/screenshots and photographs eighteen - # times without building again. Keeping it means a rerun starts at the - # first simulator instead of at the compiler. Keyed on everything the - # build reads, so a stale one is never used - only ever missed. + # Cache the screenshot build using all source and resource inputs. - name: reuse the last build uses: actions/cache@v6 with: @@ -268,9 +259,7 @@ jobs: path: ~/Library/Logs/snapshot if-no-files-found: warn - # The two halves put back together, and only now checked: a set is both - # devices in every locale, and neither runner above can see the other's. - # Republished under the names the release reads. + # Combine both device artifacts and validate the complete screenshot set. screenshot-set: needs: screenshots runs-on: ubuntu-24.04 @@ -351,9 +340,7 @@ jobs: ASC_KEY_CONTENT: ${{ secrets.ASC_KEY_CONTENT }} run: bundle exec fastlane ios ${{ matrix.lane }} - # its own job because the listing stays editable until the version is submitted, - # while a build cannot be uploaded twice. No macOS runner: this touches the - # listing, not the app + # Upload editable listing metadata independently of the binary. listing: needs: [upload, screenshot-set] # !cancelled() rather than success(), or naming a run to repair - which @@ -389,9 +376,7 @@ jobs: given: ${{ inputs.version }} run: scripts/resolve-version.py --input "$given" --dry-run "$dry_run" - # where the lane looks for them, and the same set both apps are given. - # From the run being repaired when one was named, so the pictures are the - # ones already taken rather than a quarter hour of simulators again. + # Use the same screenshots for both apps, from the specified run when repairing a listing. - name: fetch the screenshots uses: actions/download-artifact@v8 with: @@ -412,11 +397,7 @@ jobs: set -o pipefail bundle exec fastlane ios ${{ matrix.lane }} 2>&1 | tee listing.log - # The Fastfile stops deliver from sending a picture twice, so neither of - # these should ever print. They stay because both are silent in the store - # and in this log: deliver ends "Successfully uploaded all screenshots" - # and the job goes green either way. If a fastlane upgrade moves what the - # Fastfile patches, this is what still notices. + # Fail if deliver reports duplicate uploads or a truncated screenshot set. - name: the screenshots went up once each run: | set -euo pipefail diff --git a/Ads/AdSlot.swift b/Ads/AdSlot.swift index 2a1bf074..6b5c8aa6 100644 --- a/Ads/AdSlot.swift +++ b/Ads/AdSlot.swift @@ -108,9 +108,7 @@ final class AdSlot: NSObject, BannerViewDelegate { bannerView.load(Request()) } - /// Asks again for a hidden banner, which the unit will not refresh. Reschedules itself before - /// it asks: a request that never comes back must not end the chain. A suspended app cannot - /// fire the work item, so backgrounding needs no guard. + /// Retry unfilled banners, scheduling the next attempt before sending this request. private func scheduleRetry() { retry?.cancel() diff --git a/Ads/ConsentManager.swift b/Ads/ConsentManager.swift index 6dc1cf2c..19cadb74 100644 --- a/Ads/ConsentManager.swift +++ b/Ads/ConsentManager.swift @@ -8,25 +8,15 @@ Gathers advertising consent through Google's User Messaging Platform. import UIKit import UserMessagingPlatform -/// The consent form in front of the banner in the Lite configuration. -/// -/// Google's EU user consent policy requires a TCF-integrated CMP for the EEA, the UK and -/// Switzerland; UMP is Google's own, and what the Android app uses. Who is asked is decided by the -/// geo-targeting of the messages in AdMob, not here. +/// Gathers Lite ad consent through UMP using AdMob region settings. final class ConsentManager { static let manager = ConsentManager() private init() {} - /// Brings consent up to date, presenting the form where the region requires one. - /// - /// Reports `canRequestAds`: whether an answer is on file, not what it was - "do not consent" - /// leaves it true. What the answer allows is `adsMayUseAdvertisingIdentifier`. Errors do not - /// enter into it; the decision is cached, so a failed form or an offline update still leaves - /// an earlier consent standing. - /// - /// Completes on the main queue. + /// Refresh consent and present a form when required. + /// Reports canRequestAds on main; cached consent survives refresh failures. func gatherConsent(from viewController: UIViewController, completion: @escaping (Bool) -> Void) { requestUpdate { updated in guard updated else { @@ -44,13 +34,7 @@ final class ConsentManager { } } - /// Brings consent up to date without ever presenting a form. - /// - /// Needed on every launch: `privacyOptionsRequirementStatus` answers from a cache only an - /// update in the *current* session fills, so skipping it hides the privacy entry point on the - /// second launch. - /// - /// Completes on the main queue. + /// Refresh the session consent cache without presenting a form. Completes on main. func refresh(completion: @escaping () -> Void) { requestUpdate { _ in DispatchQueue.main.async { @@ -59,13 +43,8 @@ final class ConsentManager { } } - /// Whether an ad shown to this user may carry an advertising identifier, which is what ATT - /// governs. - /// - /// The first flag of the TCF signals UMP writes to `UserDefaults` is purpose 1, device storage. - /// Without it Google falls back to limited ads, which carry no identifier; refusing only - /// personalisation leaves one in play, for frequency capping and cross-app reporting. Absent - /// keys mean no TCF region. + /// Whether TCF purpose 1 (device storage) allows the advertising identifier. + /// True when no TCF consent is stored. var adsMayUseAdvertisingIdentifier: Bool { guard let purposeConsents = UserDefaults.standard.string(forKey: "IABTCF_PurposeConsents") else { return true @@ -80,10 +59,7 @@ final class ConsentManager { ConsentInformation.shared.privacyOptionsRequirementStatus == .required } - /// Reopens the consent form so a decision can be changed or withdrawn, as GDPR Art. 7(3) and - /// TCF require. Only in response to the user asking. - /// - /// Completes on the main queue. + /// Present privacy choices on user request. Completes on main. func presentPrivacyOptions(from viewController: UIViewController, completion: @escaping () -> Void) { ConsentForm.presentPrivacyOptionsForm(from: viewController) { formError in if let formError = formError { diff --git a/CHANGELOG.md b/CHANGELOG.md index d062cbcd..84560900 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ Rules: ### Changed +- Shortened code comments and corrected editing and screenshot documentation. - The engine is odrcore 7.4.0, up from 7.3.2. - A document no longer loads images from the internet. diff --git a/OpenDocumentReader/AppDelegate.swift b/OpenDocumentReader/AppDelegate.swift index 8e1cb261..be41c5f4 100644 --- a/OpenDocumentReader/AppDelegate.swift +++ b/OpenDocumentReader/AppDelegate.swift @@ -1,10 +1,4 @@ -// -// AppDelegate.swift -// OpenDocument Reader -// -// Created by Thomas Taschauer on 06.02.19. // Copyright © 2019 Thomas Taschauer. All rights reserved. -// import UIKit diff --git a/OpenDocumentReader/Constants.swift b/OpenDocumentReader/Constants.swift index 76abbfa1..db618344 100644 --- a/OpenDocumentReader/Constants.swift +++ b/OpenDocumentReader/Constants.swift @@ -1,10 +1,4 @@ -// -// Constants.swift -// OpenDocumentReader -// -// Created by Artsem Lemiasheuski on 19.12.19. // Copyright © 2019 Thomas Taschauer. All rights reserved. -// import Foundation diff --git a/OpenDocumentReader/CoreWrapper.swift b/OpenDocumentReader/CoreWrapper.swift index 85d35828..fd3f422a 100644 --- a/OpenDocumentReader/CoreWrapper.swift +++ b/OpenDocumentReader/CoreWrapper.swift @@ -31,12 +31,7 @@ private final class PageServer { var port: UInt32 { lock.withLock { handle?.port ?? 0 } } - /// Connects `service` and returns the base URL its views are served under. - /// Nil if the socket could not be opened. - /// - /// A fresh prefix every time, because the web view caches by URL: - /// re-translating after a password or an edit has to end up at an address it - /// has not seen. + /// Serve the translation under a fresh URL to bypass WebKit caches; nil on failure. func connect(_ service: HtmlService) -> URL? { lock.lock() defer { lock.unlock() } @@ -65,9 +60,7 @@ private final class PageServer { /// slide of a presentation, every page of a PDF, the entire text document. private func isCombinedView(_ view: HtmlView) -> Bool { view.name == "document" } -/// The views to show as pages, the same way OpenDocument.droid picks them: a tab -/// per sheet for spreadsheets, and for everything else the combined view alone, -/// which already holds every slide or page. +/// Use one tab per spreadsheet sheet; prefer the combined view for other documents. private func selectViews(_ views: [HtmlView], _ documentType: DocumentType) -> [HtmlView] { let isSpreadsheet = documentType == .spreadsheet let hasCombinedView = views.contains(where: isCombinedView) @@ -152,9 +145,7 @@ private func selectViews(_ views: [HtmlView], _ documentType: DocumentType) -> [ throw coreWrapperError(.unsupportedFileType, "odrcore does not render this file type") } - // the same answers OpenDocument.droid gives odrcore, so a document is - // the same document on both — the viewport meta each page carries is - // decided from these + // Keep rendering settings consistent with OpenDocument.droid. let config = HtmlConfig() config.editable = editable // how far an edit may reach: inside one paragraph, or across the @@ -163,13 +154,9 @@ private func selectViews(_ views: [HtmlView], _ documentType: DocumentType) -> [ // resource paths are resolved relative to an output directory, and in // server mode there is none — odrcore rejects the combination config.relativeResourcePaths = false - // the side margins of a printed page, which is what it was written to - // look like, and what makes odrcore call a text document paged: its - // pages are then fitted to the screen rather than shown at full size + // Keep page margins and enable paged layout. config.textDocumentMargin = true - // the reader's own appearance: odrcore writes a dark sheet behind a - // `prefers-color-scheme: dark`, which the web view answers from the - // system setting. A pdf has no dark view and stays light. + // Follow system appearance; PDFs remain light. config.colorScheme = .system // served with the pages rather than inlined as base64 config.embedImages = false diff --git a/OpenDocumentReader/Document.swift b/OpenDocumentReader/Document.swift index fd9edefc..dd8ed670 100644 --- a/OpenDocumentReader/Document.swift +++ b/OpenDocumentReader/Document.swift @@ -148,9 +148,7 @@ class Document: UIDocument { notify { $0.documentLoadingCompleted(self) } } - /// Clamped to what the document has: switching to page five of a - /// spreadsheet and then editing it into four sheets should not walk off the - /// end. + /// Clamp the selected page after a reload changes the page count. private func showPage() { guard let pageURLs, !pageURLs.isEmpty else { return } @@ -165,10 +163,7 @@ class Document: UIDocument { notify { $0.documentUpdateContent(self) } } - /// UIDocument reads on a background queue, so `load(fromContents:)` — and - /// with it everything `parse` reports — arrives off the main thread. Runs - /// inline when already there, so setting `page` or `edit` still updates the - /// view before returning. + /// Deliver UI callbacks on main, synchronously when already there. private func notify(_ body: @escaping (DocumentDelegate) -> Void) { guard !Thread.isMainThread else { if let delegate { diff --git a/OpenDocumentReader/DocumentBrowserViewController.swift b/OpenDocumentReader/DocumentBrowserViewController.swift index 45ddf6a2..4e022500 100644 --- a/OpenDocumentReader/DocumentBrowserViewController.swift +++ b/OpenDocumentReader/DocumentBrowserViewController.swift @@ -33,18 +33,14 @@ class DocumentBrowserViewController: UIDocumentBrowserViewController, UIDocument refreshPrivacyButton() } - /// Whether the screen `-ODRScreenshot` asked for is already up. - /// `viewDidAppear` runs again every time something over the browser goes - /// away, and a second document presented over the first would be the picture. + /// Stage each screenshot once, even if the browser reappears. private var isShowingScreenshot = false private func showForScreenshot(_ screen: ScreenshotMode.Screen) { guard !isShowingScreenshot else { return } isShowingScreenshot = true - // The browser is the picture. It opens on Recents, which is empty when - // the samples were laid out rather than opened, so each is revealed in - // turn to put it there. + // Reveal the samples to populate Recents before capturing the browser. guard screen != .browser else { reveal(ScreenshotMode.browserDocuments) { [weak self] in guard let self else { return } @@ -78,10 +74,7 @@ class DocumentBrowserViewController: UIDocumentBrowserViewController, UIDocument // MARK: - Privacy - /// Brings consent up to date and then offers the way back to it. - /// - /// The app has no settings screen, so the browser's chrome carries this - the only route back - /// to either choice, both of which are asked once. + /// Refresh consent and expose privacy options in the browser toolbar. private func refreshPrivacyButton() { guard Features.withAds else { return } diff --git a/OpenDocumentReader/DocumentViewController.swift b/OpenDocumentReader/DocumentViewController.swift index 8f877037..cbb7427e 100644 --- a/OpenDocumentReader/DocumentViewController.swift +++ b/OpenDocumentReader/DocumentViewController.swift @@ -186,9 +186,7 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel override func viewDidLoad() { super.viewDidLoad() - // once, not on every appearance: a second target would parse the - // document twice for a single tap, and a second set of constraints - // would fight the first + // Install targets and constraints once. pageTabBar.addTarget(self, action: #selector(pageSelected(sender:)), for: .valueChanged) webview.navigationDelegate = self webview.uiDelegate = self @@ -237,9 +235,7 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel ]) } - /// Only a link that leaves the page carries `target="_blank"`, and this app - /// has no second window: the web goes to the browser, and what odrcore - /// serves — should any of it arrive here — to the web view that asked. + /// Open served links in this web view and external links in their system app. func webView( _ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures @@ -255,10 +251,7 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel return nil } - /// odrcore renders a page only once this web view asks for it, so a document - /// that falls over halfway through translating falls over here rather than - /// in `translate`. Only the main frame counts: a link in the document - /// answering 404 is not this document failing to render. + /// Rendering is lazy, so main-frame HTTP failures count as document failures. func webView( _ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse, decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void @@ -347,9 +340,7 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel } } - // the document is drawn, which is what a screenshot of it waits for - - // and only the document: the "loading" page finishes first, and a - // picture of it is a picture of the word "loading" + // Signal screenshot readiness only after the document navigation finishes. if let documentNavigation, navigation === documentNavigation { stageScreenshot() } @@ -359,9 +350,7 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel /// says it is ready. A picture of a search is a picture of its hits. private func stageScreenshot() { switch ScreenshotMode.screen { - // The search is shown on the ODF document rather than on the pdf: as of - // odrcore 6.7.0 a hit in a pdf is drawn beside the word it found, not on - // it. Move this back to `.pdf` once a core lands that places it right. + // Use the text document for the search screenshot. case .text: let query = ScreenshotMode.query showSearchBar() @@ -369,9 +358,7 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel // the hits are the picture, not a keyboard sitting over them searchBar.resignFirstResponder() - // Ready once the hits are drawn, not once they are asked for: the - // call is asynchronous, and a picture taken in between is a picture - // of the page unsearched. + // Wait for search results before marking the screenshot ready. callSearch("odr.search", with: query) { [weak self] in guard let self else { return } @@ -450,9 +437,7 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel } } - /// From iOS 26 the bar's buttons are glass capsules filling its whole - /// height, which whatever is pinned to either edge would cut off. Older - /// bars have a background of their own and want no such gap. + /// Leave room around iOS 26 glass toolbar buttons. private static var toolBarMargin: CGFloat { if #available(iOS 26.0, *) { return 8 @@ -493,9 +478,7 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel searchBar.topAnchor.constraint(equalTo: barStack.bottomAnchor, constant: Self.toolBarMargin).isActive = true - // under the search bar, which is why the tab bar is not in the tool - // bar's stack: an arranged subview is placed by the stack, and these - // would be a second answer to the same question + // Place tabs below search, outside the toolbar stack. pageTabBar.topAnchor.constraint(equalTo: searchBar.bottomAnchor).isActive = true pageTabBar.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true pageTabBar.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true @@ -513,11 +496,7 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel bannerSlotHeight.constant = 0.0 } - /// No ad to show, so the slot promotes the paid app instead of collapsing. - /// - /// This is our own view - nothing is fetched and no identifier is read - so it is as valid on - /// the path where the user refused consent as on the one where an ad request merely came back - /// empty. + /// Show the local Pro promotion when no ad is available. private func showHouseAd() { houseAdView.rotate() @@ -630,11 +609,7 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel findAll(searchText: searchText) } - /// The pen: turns the mode on, and off again. Leaving with changes the - /// page alone holds asks first. - /// - /// It stands on the core's answer, and every edition opens what it names. - /// What Lite does not sell is the tool - see ``editToolTapped(_:)``. + /// Toggle editing, confirming unsaved changes before leaving. @IBAction func toggleEdit(_ sender: UIBarButtonItem) { if isEditingDocument { leaveEdit() @@ -841,8 +816,7 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel } } - /// Whether the page has said what it is. Not the strip itself: a sheet - /// answers with no strip at all. + /// Whether the current page's editing tools are ready. private(set) var isEditSessionReady = false /// The tools are up, which is what a screenshot of an edit waits for. @@ -1059,9 +1033,7 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel layOutDocumentTitle() } - /// The name takes what the buttons leave between the back button and the - /// rest. The bar does not say where it put a button, so the room is - /// counted from how many there are. + /// Fit the title between the back button and trailing actions. private func layOutDocumentTitle() { let buttons = (toolBar.items ?? []).filter { $0.image != nil } let start = Self.toolBarButtonWidth + Self.toolBarTitleGap @@ -1084,9 +1056,7 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel /// Kept clear either side of the name, so it never sits against a button. private static let toolBarTitleGap: CGFloat = 16 - /// While an edit is on the bar is the edit's: undo, redo and save join it, - /// and the magnifier and the name stand down - six buttons and a name do - /// not fit a phone's bar. A pdf mark is never put back, so redo stays out. + /// Show edit actions while editing; hide search and title. PDFs have no redo. private func updateToolBar() { documentTitleLabel.isHidden = isEditingDocument view.setNeedsLayout() @@ -1127,18 +1097,14 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel return document?.result != nil && components?.url == document?.result } - /// The pencil, for a document and a pdf alike: the two never stand in the - /// bar together, so the label separates them. Selected while the mode is - /// on. + /// Use the pencil for editing and PDF markup, with a distinct accessibility label. private func updateEditButtonRole() { editButton.image = UIImage(systemName: "pencil") editButton.accessibilityLabel = NSLocalizedString(canMark ? "mark_pdf" : "menu_edit", comment: "") editButton.isSelected = isEditingDocument } - /// Asked of the page rather than guessed from the format: odrcore writes the - /// `odr` object into what it renders as a document or as text, and into - /// nothing else — a pdf picks the button up on its own once it does. + /// Ask the translated page whether it supports search. private func updateSearchButton() { guard let url = webview.url, isDocumentPage(url) else { canSearch = false @@ -1223,7 +1189,8 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel } } - /// Drops the document, so viewDidDisappear does not close it a second time. + /// Close and release the document before dismissing the reader, so + /// viewDidDisappear does not close it a second time. func closeCurrentDocument(then finish: (() -> Void)? = nil) { document?.close() document = nil @@ -1347,9 +1314,7 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel printController.present(animated: true, completionHandler: nil) } - /// A page of ours rather than a document: the word "loading", or the error. - /// The colour scheme is named because a web view paints a page that claims - /// none white, whatever the reader has the device set to. + /// Loading and error pages follow system appearance. private func loadMessage(_ body: String) { webview.loadHTMLString( "\(body)", @@ -1379,10 +1344,7 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel documentNavigation = self.webview.load(URLRequest(url: url)) } - /// Three the system draws better, all falling back to `corePageInReserve`: - /// html, which odrcore has no type for and reads as its own source, iWork, - /// whose styles and pictures it does not read, and a container it knows as a - /// document (`.epub` is composite content, `.zip` is not). + /// Prefer WebKit for HTML, iWork and composite archives, with odrcore as fallback. private func systemDrawsItBetter(_ doc: Document) -> Bool { let ext = doc.fileURL.pathExtension.lowercased() diff --git a/OpenDocumentReader/DocumentWebView.swift b/OpenDocumentReader/DocumentWebView.swift index 36ddf192..7adc7c59 100644 --- a/OpenDocumentReader/DocumentWebView.swift +++ b/OpenDocumentReader/DocumentWebView.swift @@ -1,8 +1,6 @@ import WebKit -/// Built here rather than decoded: a nib turns every data detector on, and a -/// configuration cannot be changed once the view has it. Detectors draw links -/// over the invisible text a pdf carries for selection and search. +/// Disable data detectors before creating the web view; their overlays obscure PDF text. final class DocumentWebView: WKWebView { required init?(coder: NSCoder) { diff --git a/OpenDocumentReader/EditToolBar.swift b/OpenDocumentReader/EditToolBar.swift index 303d421f..6db827ae 100644 --- a/OpenDocumentReader/EditToolBar.swift +++ b/OpenDocumentReader/EditToolBar.swift @@ -1,11 +1,7 @@ import UIKit -/// The strip of tools under the bar: what changes the text. Undo, redo and -/// save are the bar's. -/// -/// A tap does the tool's one job, and a long press opens the colours it -/// applies. Without ``advancedEditing`` every tool but the highlighter is -/// dimmed and offers Pro instead. +/// Formatting and PDF tools. Tap to apply; long-press for colors. +/// Locked tools offer Pro, while highlighting stays available. final class EditToolBar: UIView { /// One button of the strip. @@ -618,9 +614,7 @@ final class EditToolBar: UIView { return UIMenu(title: tool.label, children: children) } - /// A letter over a wave, shaped like the `underline` and `strikethrough` - /// beside it. The system set has no wavy underline. The letter is the - /// English one and does not follow the language, which a system glyph does. + /// Custom wavy-underline icon; SF Symbols has no equivalent. private static let squigglyImage: UIImage = { let size = CGSize(width: iconSize, height: iconSize) diff --git a/OpenDocumentReader/HouseAdView.swift b/OpenDocumentReader/HouseAdView.swift index 18dd8e2b..13bac95e 100644 --- a/OpenDocumentReader/HouseAdView.swift +++ b/OpenDocumentReader/HouseAdView.swift @@ -1,14 +1,8 @@ import StoreKit import UIKit -/// The promotion for the paid app that takes the banner's place when no ad arrives. -/// -/// This is our own content, not an ad: nothing is fetched, no identifier is read and no storage is -/// touched. That is what makes it usable on every no-fill path, including the one where the user -/// refused consent outright and Google is serving limited ads or nothing at all. -/// -/// One layout, sized to whatever the slot gives it - the banner height is fixed by the ad slot and -/// cannot grow, so parts drop out instead of wrapping. +/// Local Pro promotion shown when no ad fills the banner. +/// Hide content as space shrinks to preserve the fixed banner height. final class HouseAdView: UIView { /// One rotation of the promotion. `shortHeadline` is what a phone-width slot can actually fit. diff --git a/OpenDocumentReader/NonFree/AnalyticsManager.swift b/OpenDocumentReader/NonFree/AnalyticsManager.swift index b99f72a4..9becfc01 100644 --- a/OpenDocumentReader/NonFree/AnalyticsManager.swift +++ b/OpenDocumentReader/NonFree/AnalyticsManager.swift @@ -1,8 +1,7 @@ import Foundation import os -// Event and parameter names kept from the Firebase days so the reports stay -// comparable with OpenDocument.droid, which uses the same identifiers. +// Event and parameter names shared with OpenDocument.droid. enum AnalyticsConstants { static let paramContentType = "content_type" static let paramContent = "content" @@ -14,9 +13,7 @@ enum AnalyticsConstants { static let eventAddToCart = "add_to_cart" } -/// Analytics used to go to Firebase. OpenDocument.droid dropped its Firebase -/// dependency, so this mirrors the shell its `nonfree` package keeps: the call -/// sites stay, but nothing leaves the device, so nothing switches it off. +/// Logs analytics events locally without uploading them. final class AnalyticsManager { static let shared = AnalyticsManager() diff --git a/OpenDocumentReader/NonFree/CrashManager.swift b/OpenDocumentReader/NonFree/CrashManager.swift index ec531a09..96dd3c7e 100644 --- a/OpenDocumentReader/NonFree/CrashManager.swift +++ b/OpenDocumentReader/NonFree/CrashManager.swift @@ -1,10 +1,7 @@ import Foundation import os -/// Crash reporting used to go to Crashlytics. OpenDocument.droid dropped its -/// Firebase dependency, so this mirrors the shell its `nonfree` package keeps: -/// errors are logged locally instead of being uploaded, so nothing switches it -/// off. +/// Logs errors locally without uploading them. final class CrashManager { static let shared = CrashManager() diff --git a/OpenDocumentReader/PageTabBar.swift b/OpenDocumentReader/PageTabBar.swift index 433d127d..6a856ead 100644 --- a/OpenDocumentReader/PageTabBar.swift +++ b/OpenDocumentReader/PageTabBar.swift @@ -1,11 +1,6 @@ import UIKit -/// A horizontally scrollable row of text tabs, one per document page. -/// -/// Replaces the vendored ScrollableSegmentedControl, whose upstream was archived -/// in 2022, and covers only what the document view asked of it: text tabs that -/// share the available width while they fit, scroll once they do not, and -/// underline the selected one. +/// Scrollable page tabs with an underline for the selected page. final class PageTabBar: UIControl { static let titlePadding: CGFloat = 8 static let underlineHeight: CGFloat = 4 @@ -114,10 +109,7 @@ final class PageTabBar: UIControl { resizeTabs() } - /// Every tab gets an even share of the row while that is wide enough for the - /// longest title. Failing that they keep their own widths and share out - /// whatever is left over, so a single long sheet name is neither truncated - /// nor allowed to dictate the width of the short ones. + /// Use equal widths when titles fit; otherwise preserve title widths and distribute spare space. private func resizeTabs() { guard let widestTitle = titleWidths.max() else { tabWidths = [] diff --git a/OpenDocumentReader/ScreenshotMode.swift b/OpenDocumentReader/ScreenshotMode.swift index 35f83a69..4c48330a 100644 --- a/OpenDocumentReader/ScreenshotMode.swift +++ b/OpenDocumentReader/ScreenshotMode.swift @@ -1,21 +1,10 @@ import UIKit -/// The back door the App Store screenshots are taken through. -/// -/// `-ODRScreenshot text` puts the app on one screen and leaves it there, so -/// `fastlane snapshot` photographs the same thing in every language without -/// having to drive the document browser - which is Apple's own UI, in eleven -/// languages, and would be a different tap in each. -/// -/// Off in every build nobody passes that argument to, and compiled to nothing -/// outside Debug. The documents it opens are bundled the same way: written by -/// `scripts/make-screenshot-documents.py`, and kept out of the archive that -/// goes to the store by `EXCLUDED_SOURCE_FILE_NAMES`. +/// Stages screenshots with -ODRScreenshot in Debug builds. +/// Samples are generated by scripts/make-screenshot-documents.py and excluded from Release. enum ScreenshotMode { - /// What one launch shows. The screenshot test launches once per case and - /// takes one picture, so this list and the one in - /// `scripts/store-screenshots.py` are the same list. + /// One launch per screen; keep these cases aligned with scripts/store_screenshots.py. enum Screen: String { /// the document browser, with one of each format sitting in it case browser @@ -94,16 +83,10 @@ enum ScreenshotMode { Sample(name: "manual", format: "pdf", from: "paper"), ] - /// Set on the view the picture is of, once it has something to show. The - /// test waits for it rather than for a fixed number of seconds, because - /// translating a document takes as long as the simulator takes. + /// Signals that the screenshot content is ready. static let readyIdentifier = "odr-screenshot-ready" - /// `nil` unless this launch was asked for a screenshot. - /// - /// Read from `UserDefaults` rather than from the argument list, because - /// `-ODRScreenshot text` on the command line is a default: that is how - /// XCUITest's `launchArguments` arrive. + /// Reads the Debug-only -ODRScreenshot launch argument through UserDefaults. static var screen: Screen? { #if DEBUG guard let name = UserDefaults.standard.string(forKey: "ODRScreenshot") else { return nil } @@ -117,19 +100,12 @@ enum ScreenshotMode { /// Where each sample ended up, by name, once it was laid out. private(set) static var laidOut: [String: URL] = [:] - /// The browser's own picture, in the order it should reveal them. - /// - /// Revealing a document puts it in Recents, which is the tab the browser - /// opens on, and Recents shows the newest first - so this is back to front. + /// Reveal samples in reverse order because Recents lists the newest first. static var browserDocuments: [URL] { folder.reversed().compactMap { laidOut[$0.name] } } - /// Lays the whole folder out before anything lists it. - /// - /// All of it on every screen, not just the one being photographed: the - /// browser is behind every other picture, and a folder holding one file - /// looks like a folder nobody uses. + /// Prepare all sample files before the document browser loads. static func prepare() { guard screen != nil else { return } @@ -144,9 +120,7 @@ enum ScreenshotMode { } } - /// Clears the folder first. A simulator is kept between runs and between - /// languages, so without this the picture is this run's documents plus - /// every run before it, under whatever names those used. + /// Remove samples from previous screenshot runs and locales. private static func empty() { guard let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first, let existing = try? FileManager.default.contentsOfDirectory( @@ -160,12 +134,7 @@ enum ScreenshotMode { } } - /// What the folder is called and what the search screenshot looks for, in - /// the app's language. - /// - /// Written beside the samples by `make-screenshot-documents.py`, because - /// these are read by nobody but the screenshots and have no business in - /// `Localizable.strings`. + /// Localized sample names and search text from make-screenshot-documents.py. private struct Details: Decodable { let files: [String: String] let search: String @@ -189,16 +158,10 @@ enum ScreenshotMode { return all["en"] } - /// The word the search screenshot types. Whatever the pdf it is looking at - /// actually says, which is not always this language. + /// The word the search screenshot looks for in the text sample. private(set) static var query = "" - /// The sample document of this screen, in the app's language, copied out of - /// the bundle into the documents directory - which is where the document - /// browser looks, and the only place the app may write. - /// - /// English where the language has no sample of its own, which is what that - /// storefront's screenshot would show anyway: the app has no UI in it either. + /// Copy the localized sample to Documents, falling back to English. private static func layOut(_ sample: Sample, called title: String) -> URL? { var source: URL? for language in Bundle.main.preferredLocalizations + ["en"] { diff --git a/OpenDocumentReader/StoreReviewHelper.swift b/OpenDocumentReader/StoreReviewHelper.swift index c44d7c21..c8c067b3 100644 --- a/OpenDocumentReader/StoreReviewHelper.swift +++ b/OpenDocumentReader/StoreReviewHelper.swift @@ -1,10 +1,4 @@ -// -// StoreReviewHelper.swift -// Template1 -// -// Created by Apple on 14/11/17. // Copyright © 2017 Mobiotics. All rights reserved. -// import Foundation import StoreKit diff --git a/OpenDocumentReaderTests/DeclaredDocumentTypesTests.swift b/OpenDocumentReaderTests/DeclaredDocumentTypesTests.swift index 31df9ba6..15d546fb 100644 --- a/OpenDocumentReaderTests/DeclaredDocumentTypesTests.swift +++ b/OpenDocumentReaderTests/DeclaredDocumentTypesTests.swift @@ -4,9 +4,6 @@ import XCTest @testable import OpenDocumentReader -/// What the app offers itself for, held against odrcore's format table the way -/// OpenDocument.droid holds its manifest against the same table. The plist -/// cannot read the table, so this is what keeps the two from drifting. class DeclaredDocumentTypesTests: XCTestCase { /// Every `LSItemContentTypes` entry of the built app, not of a copy kept @@ -18,10 +15,6 @@ class DeclaredDocumentTypesTests: XCTestCase { return entries.flatMap { $0["LSItemContentTypes"] as? [String] ?? [] }.compactMap { UTType($0) } }() - /// What the app offers itself for: odrcore's document formats, plus the two - /// non-document ones worth opening a viewer for. Narrower than everything - /// odrcore translates - it does images, media, fonts and archives too, and - /// the app does not want to be the handler for an mp3. private var offeredFileTypes: [FileType] { Odr.allFileTypes.compactMap { FileType(rawValue: $0.intValue) } .filter { type in @@ -48,9 +41,6 @@ class DeclaredDocumentTypesTests: XCTestCase { } } - /// A dynamic type is what iOS invents for an extension it does not know. It - /// conforms to `public.data` and nothing else, so a file carrying one is - /// greyed out in the browser however broad the claims are. func testNoOfferedFormatResolvesToADynamicType() { for type in offeredFileTypes { for fileExtension in Odr.extensions(fileType: type) { diff --git a/OpenDocumentReaderTests/DocumentTitleTests.swift b/OpenDocumentReaderTests/DocumentTitleTests.swift index 26cd548d..876b80bf 100644 --- a/OpenDocumentReaderTests/DocumentTitleTests.swift +++ b/OpenDocumentReaderTests/DocumentTitleTests.swift @@ -58,9 +58,6 @@ class DocumentTitleTests: XCTestCase { controller.view.layoutIfNeeded() } - /// Where a button is drawn, read through a private key: the bar has no - /// public way to say. The iOS 26 bar does not draw a button put back into it - /// while the window is a test's, so there the test cannot look. private func frame(of item: UIBarButtonItem) throws -> CGRect { guard let view = item.value(forKey: "view") as? UIView, view.window != nil, view.bounds.width > 0 else { throw XCTSkip("the bar did not draw this button") diff --git a/OpenDocumentReaderTests/EditWorkflowTests.swift b/OpenDocumentReaderTests/EditWorkflowTests.swift index 53676624..c9f05451 100644 --- a/OpenDocumentReaderTests/EditWorkflowTests.swift +++ b/OpenDocumentReaderTests/EditWorkflowTests.swift @@ -3,9 +3,6 @@ import XCTest @testable import OpenDocumentReader -/// The whole round trip, driven through the real view controller rather than -/// through ``CoreWrapper`` alone: the bar is half of what makes editing work, -/// and it is the half that broke. class EditWorkflowTests: XCTestCase { private var documentURL: URL! private var window: UIWindow! @@ -210,9 +207,6 @@ class EditWorkflowTests: XCTestCase { // MARK: - the page - /// The one thing edit mode is for. odrcore makes the flow `contenteditable`, - /// but a tap has to reach a run for the caret to be set and the keyboard to - /// unfold. func testTappingTheTextReachesTheEditableRun() throws { openDocument() @@ -668,9 +662,6 @@ class EditWorkflowTests: XCTestCase { waitForPage(where: "document.querySelectorAll('[contenteditable]').length > 0") } - /// The controller is its own navigation delegate — taking that away is what - /// tells the tool bar what the page can do — so the test waits on the page - /// itself rather than on `didFinish`. private func waitForPage( where condition: String, file: StaticString = #filePath, line: UInt = #line ) { diff --git a/OpenDocumentReaderTests/LockedDocumentTests.swift b/OpenDocumentReaderTests/LockedDocumentTests.swift index eb4dd91d..86fbfded 100644 --- a/OpenDocumentReaderTests/LockedDocumentTests.swift +++ b/OpenDocumentReaderTests/LockedDocumentTests.swift @@ -3,10 +3,6 @@ import XCTest @testable import OpenDocumentReader -/// A legacy Word, Excel or PowerPoint file can say it is password protected, -/// and no password opens it: odrcore reads the flag but cannot decrypt any of -/// them. The reader has to say so rather than ask, and rather than hand the -/// file to the web view, which makes nothing of it either. class LockedDocumentTests: XCTestCase { private let temporaryDirectory = NSTemporaryDirectory() private var documentURL: URL! diff --git a/OpenDocumentReaderTests/OpenDocumentReaderTests.swift b/OpenDocumentReaderTests/OpenDocumentReaderTests.swift index 24a34055..6ab81d5e 100644 --- a/OpenDocumentReaderTests/OpenDocumentReaderTests.swift +++ b/OpenDocumentReaderTests/OpenDocumentReaderTests.swift @@ -1,10 +1,4 @@ -// -// OpenDocumentReaderTests.swift -// OpenDocumentReaderTests -// -// Created by Thomas Taschauer on 08.11.20. // Copyright © 2020 Thomas Taschauer. All rights reserved. -// import WebKit import XCTest @@ -227,9 +221,6 @@ class OpenDocumentReaderTests: XCTestCase { XCTAssertTrue(html.contains("(prefers-color-scheme: dark)"), html) } - /// The page measures the view and fits itself to it. A web view does not fit - /// a page to the screen the way a browser does, and the app used to carry a - /// script of its own for this. func testAPageFitsItselfToTheView() throws { let wrapper = CoreWrapper() @@ -257,9 +248,6 @@ class OpenDocumentReaderTests: XCTestCase { XCTAssertNotEqual(before, wrapper.pageURLs) } - /// A missing `NSAllowsLocalNetworking` shows up here and nowhere else: App - /// Transport Security applies to the web view, not to the `URLSession` - /// above. func testTheWebViewLoadsAServedPage() throws { let wrapper = CoreWrapper() diff --git a/OpenDocumentReaderTests/PageTabBarTests.swift b/OpenDocumentReaderTests/PageTabBarTests.swift index 0224bead..f02134a9 100644 --- a/OpenDocumentReaderTests/PageTabBarTests.swift +++ b/OpenDocumentReaderTests/PageTabBarTests.swift @@ -105,9 +105,6 @@ class PageTabBarTests: XCTestCase { XCTAssertLessThan(widths[1], widths[2] / 2) } - /// An even share is only fair while it is wide enough for the longest - /// title: all three of these fit the bar, but an even third would truncate - /// the long one for no reason. func testALongTitleKeepsItsWidthWhileTheyAllStillFit() throws { tabBar.titles = ["A", "B", "Q4 Revenue Forecast"] diff --git a/OpenDocumentReaderTests/PrintAppearanceTests.swift b/OpenDocumentReaderTests/PrintAppearanceTests.swift index 76e9cce6..e03cc904 100644 --- a/OpenDocumentReaderTests/PrintAppearanceTests.swift +++ b/OpenDocumentReaderTests/PrintAppearanceTests.swift @@ -4,9 +4,6 @@ import XCTest @testable import OpenDocumentReader -/// Print takes the page the web view is showing, and since odrcore renders a -/// document in the reader's own appearance that page can be dark. Paper is not: -/// a dark page printed as it stands is pale ink on white, which is nothing. class PrintAppearanceTests: XCTestCase { /// The web view prints in light whatever the device is set to, so the menu diff --git a/OpenDocumentReaderUITests/ScreenshotTests.swift b/OpenDocumentReaderUITests/ScreenshotTests.swift index d0d636cc..b215bab7 100644 --- a/OpenDocumentReaderUITests/ScreenshotTests.swift +++ b/OpenDocumentReaderUITests/ScreenshotTests.swift @@ -1,16 +1,7 @@ import XCTest -/// The pictures the App Store shows. -/// -/// One launch per screen, each with `-ODRScreenshot `, which is the -/// app's own back door: it puts itself on that screen and stays there. Nothing -/// here taps anything, because everything there is to tap on the way - the -/// document browser - is Apple's UI in eleven languages. -/// -/// `fastlane snapshot` runs this once per language per device and collects what -/// it writes. `scripts/store-screenshots.py` then checks the set and stages it -/// for the release; the names below are the names it expects, and their order is -/// the order the store shows them in. +/// Capture each store screen in a separate launch with -ODRScreenshot . +/// Fastlane repeats the test for each device and locale. final class ScreenshotTests: XCTestCase { /// Each picture's name, and the screen it is of. The number is the place @@ -45,10 +36,7 @@ final class ScreenshotTests: XCTestCase { // the language and the region this run is for, once Snapshots.prepare(app) - // The keyboard offers to teach swipe typing the first time it comes up - // on a fresh simulator, over the keyboard the edit screenshot is of. - // Told here that it has already been shown, so nothing has to be tapped - // away in a language this test cannot read. + // Suppress the first-run swipe keyboard tutorial. app.launchArguments += ["-DidShowContinuousPathIntroduction", "1"] let arguments = app.launchArguments @@ -78,11 +66,7 @@ final class ScreenshotTests: XCTestCase { } } - /// Waits for the browser to have listed the documents. - /// - /// Recents is filled by the system a few at a time and the app cannot say - /// when it is done, so this waits for the count to stop growing. The first - /// cell is not the end of it. + /// Wait until the Recents file count stabilizes. @MainActor private func waitForTheFolderToFill(in app: XCUIApplication) { let cells = app.collectionViews.cells @@ -106,15 +90,7 @@ final class ScreenshotTests: XCTestCase { } } - /// Taps into the text of the document being edited. - /// - /// A real tap, not a focus call from the app: WebKit only raises the - /// keyboard for a gesture it saw, so an edit staged entirely in code sets a - /// caret and nothing else. - /// - /// Where the text is depends on the page, and a tap into its margin reaches - /// nothing, so this works down the page until the keyboard answers rather - /// than betting the run on one offset. + /// Tap document text to raise the keyboard; WebKit requires a user gesture. @MainActor private func raiseTheKeyboard(in app: XCUIApplication) { let page = app.webViews.firstMatch @@ -135,16 +111,12 @@ final class ScreenshotTests: XCTestCase { XCTFail("no tap down the page set a caret, so the keyboard never came up") } - /// Belt and braces for the swipe typing panel, in case the launch argument - /// stops being honoured. Only reaches an English keyboard, which is why it - /// is the second line of defence and not the first. + /// Dismiss the English keyboard tutorial if the launch flag did not suppress it. @MainActor private func dismissTheKeyboardTutorial(in app: XCUIApplication) { let continueButton = app.buttons["Continue"] - // Short, because this waits its whole timeout on every run that does not - // need it - which is all of them while the launch argument holds. The - // panel comes up with the keyboard, and the keyboard is already up here. + // Keep this fallback wait short on runs without a tutorial. if continueButton.waitForExistence(timeout: 0.5) { continueButton.tap() } diff --git a/OpenDocumentReaderUITests/Snapshots.swift b/OpenDocumentReaderUITests/Snapshots.swift index 0a18b0cd..31776ad3 100644 --- a/OpenDocumentReaderUITests/Snapshots.swift +++ b/OpenDocumentReaderUITests/Snapshots.swift @@ -1,26 +1,8 @@ import Foundation import XCTest -/// The hand-off to `fastlane snapshot`, which is a directory and a file name. -/// -/// Nothing is passed in process. `capture_ios_screenshots` writes what the run -/// is for into a cache directory on the host, launches this bundle once per -/// language, and afterwards collects whatever PNGs it finds there. That is the -/// whole protocol: two files read, one directory written. -/// -/// fastlane ships a `SnapshotHelper.swift` to do this, and it used to sit here -/// verbatim - three hundred lines carrying macOS, tvOS and watchOS branches, a -/// landscape correction, and a wait on the network activity indicator that iOS -/// has not drawn since 13. None of it applied to this app, and none of it could -/// be read or formatted like the rest of these sources, because a copied file -/// has to stay byte for byte theirs to be replaceable. This is the part we use, -/// written as ours. -/// -/// One thing theirs does that this does not: it passes on the `launch_arguments` -/// from the lane, through a third file. The lane sets none, and the app takes -/// the only argument it cares about from ``ScreenshotTests`` directly. -/// -/// https://docs.fastlane.tools/actions/snapshot/ +/// Read fastlane language and locale files and write simulator screenshots. +/// Launch arguments are set by ScreenshotTests. See https://docs.fastlane.tools/actions/snapshot/. enum Snapshots { /// Where fastlane leaves what it wants, and looks for what it gets back. @@ -37,11 +19,7 @@ enum Snapshots { handOff?.appendingPathComponent("screenshots", isDirectory: true) } - /// Puts the language and region this run is for on the app's arguments. - /// - /// One launch of this bundle is one language. The app cannot work out which - /// on its own - the simulator is the same one every time - so fastlane names - /// it in a file and it is handed over here. + /// Pass fastlane language and locale settings to the app. @MainActor static func prepare(_ app: XCUIApplication) { guard let handOff else { @@ -55,9 +33,7 @@ enum Snapshots { app.launchArguments += ["-AppleLanguages", "(\(language))"] } - // The region decides how a date and a number are written, which is half - // of what a spreadsheet screenshot shows. It follows the language when - // the lane does not name one of its own. + // Use the language as the locale fallback for dates and numbers. let region = read(handOff.appendingPathComponent("locale.txt")) ?? language.map { Locale(identifier: $0).identifier } @@ -66,10 +42,7 @@ enum Snapshots { } } - /// Writes the screen under the name the release expects to find. - /// - /// `scripts/store-screenshots.py` reads these back by name, so the shape of - /// it - the device, then the screen - is a promise to that script. + /// Write -.png for scripts/store_screenshots.py. @MainActor static func take(_ name: String) { guard let pictures, diff --git a/README.md b/README.md index d8383385..29525321 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ Security blocks plain HTTP. - The pencil calls `odr.editing.enable()`. The page stays in place. The same pencil marks up a PDF. - The bar holds undo, redo and save. The strip (`EditToolBar`) holds the text - formatting. A sheet or a plain text file shows no strip. + and cell formatting. Plain text files show no formatting strip. - A tap uses a tool. A long press opens its colours. Do not add chevrons. - Redo is hidden over a PDF. The magnifier is hidden during an edit. - A PDF tool is armed by the page (`odr.annotation.press`), and the app never diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 0b8b6d86..67997b6e 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -6,24 +6,8 @@ require "tmpdir" # deliver, whose upload_to_app_store this ends in require "deliver" -# deliver sends every picture, then reads the set back from App Store Connect -# and sends again whatever it cannot find. The store lists a picture some -# seconds after it takes it, so what deliver cannot find is only the locales it -# sent last - sv and tr, at the end of the alphabet. The second copy joins the -# first, fills the locale to the ten the store allows, and the pictures that no -# longer fit are then dropped with a line nobody reads. The lane still ends -# "Successfully uploaded all screenshots". -# -# Not one upload has failed here; only the reading back has. So wait and read -# again, and if a picture really is missing, stop and say so - never send a -# second copy. -# -# class_eval on the class rather than `module Deliver ... end`: fastlane evals -# this file against a binding scoped to Fastlane::FastFile, so reopening it by -# name defines Fastlane::FastFile::Deliver and leaves the real deliver running. -# Reading a constant still finds the real one, so nothing says a word - which -# is how the first go at this shipped, patched nothing, and doubled 1.41's -# screenshots a third time. +# Wait for App Store Connect to list uploaded screenshots without resending them. +# Patch via class_eval because Fastlane evaluates this file in its own namespace. LOOKS = 7 LOOK_AGAIN_AFTER = 30 WAITS_AT_MOST = (LOOKS - 1) * LOOK_AGAIN_AFTER @@ -67,17 +51,13 @@ end default_platform(:ios) -# name also picks the app's half of the listing: fastlane/metadata is what both -# say, and fastlane/metadata-pro and -lite are where they differ. An app's name -# has to be unique in the store, so that much always differs. +# The app name selects its metadata override directory. APPS = { pro: { name: "pro", scheme: "ODR Full", app_identifier: "at.tomtasche.reader" }, lite: { name: "lite", scheme: "ODR Lite", app_identifier: "at.tomtasche.reader.lite1" }, }.freeze -# Absolute, because lane bodies run in fastlane/ while actions run one directory -# up: a bare "build" would be fastlane/build/ to File.exist? and build/ to gym. -# Resolved at parse time, which fastlane also does from fastlane/. +# Resolve paths before lane and action working directories diverge. IPA_DIR = File.expand_path("../build").freeze # Absolute for the same reason. They resolve the repository from their own path, @@ -92,15 +72,10 @@ MAKE_DOCUMENTS = File.expand_path("../scripts/make-screenshot-documents.py").fre SCREENSHOT_DIR = File.expand_path("screenshots").freeze FRAMED_DIR = File.expand_path("framed").freeze -# Named, where snapshot would pick a fresh temp directory each run. The app is -# built once into it and then photographed eighteen times without building -# again, and CI keeps it between runs. +# Reuse one build across screenshot devices and locales. SCREENSHOT_BUILD_DIR = File.expand_path("../build/screenshots").freeze -# The two sizes App Store Connect asks an app that runs on both for, named by -# whatever the runner's Xcode calls them. Newest first, and the first one the -# runner actually has wins - a simulator's name changes with every Xcode, the -# number of pixels it has does not, and store_screenshots.py checks that. +# Choose the newest installed iPhone and iPad models from these candidates. SCREENSHOT_DEVICES = { "iphone" => ["iPhone 17 Pro Max", "iPhone 16 Pro Max", "iPhone 15 Pro Max"], "ipad" => ["iPad Pro 13-inch (M5)", "iPad Pro 13-inch (M4)", "iPad Pro (12.9-inch) (6th generation)"], @@ -142,9 +117,7 @@ def screenshot_ios newest end -# The locales to photograph. Every one the app is translated into, unless -# ODR_SCREENSHOT_LANGUAGES names fewer - which is how to look at one language's -# pictures without waiting out the other eight. +# Capture all supported locales unless ODR_SCREENSHOT_LANGUAGES narrows the set. def screenshot_languages given = ENV["ODR_SCREENSHOT_LANGUAGES"].to_s.strip return given.split(",").map(&:strip) unless given.empty? @@ -152,9 +125,7 @@ def screenshot_languages sh(STORE_SCREENSHOTS, "--languages", log: false).split("\n").map(&:strip).reject(&:empty?) end -# One simulator per device the store wants, out of the ones that run the iOS -# being photographed, or a failure naming what is there instead - which beats -# xcodebuild spending twenty minutes not finding it. +# Choose one installed simulator per requested device type. def screenshot_devices(ios) given = ENV["ODR_SCREENSHOT_DEVICES"].to_s.strip return given.split(",").map(&:strip) unless given.empty? @@ -224,16 +195,10 @@ platform :ios do UI.message("photographing #{devices.join(' and ')} on iOS #{ios} in #{languages.join(', ')}") - # The documents in the pictures, written rather than committed: they are - # build output, and the only thing that opens them is the run below. Ahead - # of the build, because the target picks its resources up off the disk as - # it starts - a file written later lands in the next build, not this one. + # Generate sample resources before Xcode discovers build inputs. sh(MAKE_DOCUMENTS) - # Built once, here. snapshot asks xcodebuild to build and then test on every - # run, and there are eighteen of them - two devices in nine languages. The - # build is the same every time, and checking it is still up to date costs - # about two minutes on a runner. + # Build once for all screenshot runs. scan( project: "OpenDocumentReader.xcodeproj", scheme: "ODR Screenshots", @@ -244,11 +209,8 @@ platform :ios do skip_detect_devices: true ) - # The Pro scheme, so no ad sdk is linked and no consent form can come up in - # front of a picture. The two apps differ only in the edit, where Lite dims - # the Pro tools: the run takes that screen a second time as Lite shows it, - # and each listing gets its own edit. The banner Lite carries is in no - # picture either way. + # Use Pro for both listings so ad and consent dialogs cannot obscure screenshots. + # The run captures the edit screen a second time as Lite shows it. capture_ios_screenshots( project: "OpenDocumentReader.xcodeproj", scheme: "ODR Screenshots", @@ -258,47 +220,31 @@ platform :ios do ios_version: ios, languages: languages, output_directory: SCREENSHOT_DIR, - # 9:41 and a full battery, as Apple's own screenshots have. Spelled out - # because the default asks for "charged", which iOS 26 draws as a green - # charging battery - Apple's own pictures show a plain full one. + # Use 9:41 and a full, non-charging battery. override_status_bar: true, override_status_bar_arguments: "--time 9:41 --dataNetwork wifi --wifiMode active --wifiBars 3 " \ "--cellularMode active --cellularBars 4 --batteryState discharging --batteryLevel 100", # a rerun replaces the set rather than adding to it clear_previous_screenshots: true, - # snapshot refuses to start without a file named SnapshotHelper.swift, - # and checks its version marker. We do not carry theirs - the hand-off is - # OpenDocumentReaderUITests/Snapshots.swift, which is ours - so there is - # nothing for it to find and nothing for it to check. + # Snapshots.swift implements the handoff; no vendored SnapshotHelper.swift to check. skip_helper_version_check: true, # the runner has no browser to open the summary in, and no one watching it skip_open_summary: true, stop_after_first_error: true, - # one device per xcodebuild rather than both at once: two simulators - # rendering on a three core runner took ten minutes over one, and ran the - # test out of the time XCTest allows it + # Run one simulator at a time; two at once overran the XCTest time limit. concurrent_simulators: false, # the build above, photographed as it stands derived_data_path: SCREENSHOT_BUILD_DIR, test_without_building: true, - # the language the app is given is not the language the status bar is - # drawn in, and an iPad's status bar carries the date. Without this it - # reads Mon Aug 17 over a Russian document. + # Localize the simulator status bar as well as the app. localize_simulator: true ) - # A raw capture is not what the store shows. The framing is separate from the - # photography so that changing a headline costs a second of Pillow rather - # than a quarter hour of simulators - rerun this script alone after an edit - # to fastlane/frames/frames.json. + # Frame captures separately so design changes do not require new screenshots. sh(FRAME_SCREENSHOTS) - # what came out is what the store would be given, so it is checked here - # rather than at upload time on the other side of the run. A run narrowed to - # some of the languages or to one of the devices has nothing to check - # against: half a set is what it was asked for, and the release checks the - # two halves together once both runners have handed theirs in. + # Validate complete sets; the release workflow checks combined device jobs. narrowed = %w[ODR_SCREENSHOT_LANGUAGES ODR_SCREENSHOT_DEVICE ODR_SCREENSHOT_DEVICES] .any? { |name| !ENV[name].to_s.strip.empty? } @@ -342,9 +288,7 @@ platform :ios do ) end - # ASC_KEY_CONTENT is the base64 of the .p8. It is written to a private - # temporary file because that is the shape app_store_connect_api_key takes it - # in; every caller removes it again. + # Decode the API key into a private temporary file; callers remove it. private_lane :api_key_file do path = File.join(Dir.mktmpdir("asc-api-key"), "AuthKey_#{ENV.fetch('ASC_KEY_ID')}.p8") File.write(path, Base64.decode64(ENV.fetch("ASC_KEY_CONTENT"))) @@ -374,16 +318,11 @@ platform :ios do }.max + 1 end - # ODR_VERSION and ODR_BUILD_NUMBER come from the environment rather than lane - # options, which fastlane passes through as strings: `dry_run:false` would - # arrive as "false" and read as true. An unset ODR_BUILD_NUMBER asks App Store - # Connect itself, as a hand run does. + # Read version and build from the environment; resolve an unset build number from the store. private_lane :build_ipa do |options| version = ENV["ODR_VERSION"].to_s.strip - # resolve-version.py refuses this in CI, but these lanes are meant to be - # runnable by hand, where nothing else stops an unversioned build before the - # upload rejects 0.0.0 twenty minutes in + # Require a version for local uploads as well as CI. if version.empty? && !dry_run? UI.user_error!( "no version to build: set ODR_VERSION (e.g. ODR_VERSION=1.36), " \ @@ -402,11 +341,7 @@ platform :ios do UI.message("building #{options[:scheme]} #{version.empty? ? '(unversioned)' : version} as build #{build_number}") - # Manual, because automatic signing has xcodebuild mint distribution assets - # of its own - cloud signing, which only an Admin key may do, and which - # left the imported certificate unused. sigh wants an Admin key only when - # the account has no profile yet and it has to create one; that fails here, - # before the build, rather than after it. + # Use manual signing and fetch the provisioning profile before building. profile_dir = Dir.mktmpdir("provisioning-profile") get_provisioning_profile( api_key: key, @@ -461,9 +396,7 @@ platform :ios do ipa: ipa, skip_screenshots: true, skip_metadata: true, - # uploading is not the same as shipping: promoting a build stays a - # deliberate step in App Store Connect. deliver's option is - # submit_for_review; skip_submission is pilot's and is rejected here. + # Upload without submitting for review. submit_for_review: false, # deliver runs precheck even when it is not submitting, and precheck # refuses to run at all against in-app purchases with an API key @@ -474,18 +407,7 @@ platform :ios do end end - # What the store says about the app: the "What's New" of ODR_VERSION in every - # locale, and the rest of the listing text with it. - # - # deliver uploads every metadata file it finds under metadata_path, so it is - # given a staged directory rather than fastlane/metadata itself, and - # store_listing.py names what goes into it. What is staged is what this - # repository says the store should say; anything else is left as App Store - # Connect has it. - # - # Separate from upload_ipa so a rejected word can be rewritten and pushed again - # without touching the binary, which App Store Connect refuses a second time - # anyway. + # Stage and upload version notes and app-specific listing metadata separately from the binary. private_lane :upload_listing do |options| version = ENV["ODR_VERSION"].to_s.strip UI.user_error!("no version to write notes for: set ODR_VERSION (e.g. ODR_VERSION=1.41)") if version.empty? @@ -499,10 +421,7 @@ platform :ios do # ever written to disk sh(STORE_LISTING, "--version", version, "--stage", notes_dir, "--app", options[:name]) - # A run with nothing captured writes the text alone, so fixing a word in a - # description does not cost a quarter hour of simulators. A run with - # something captured has to have all of it: half a set is worse in the - # store than the set already up there. + # Upload text alone when no screenshots exist; otherwise require a complete set. captured = !Dir.glob(File.join(FRAMED_DIR, "*", "*.png")).empty? sh(STORE_SCREENSHOTS, "--screenshots", FRAMED_DIR, "--stage", shots_dir, "--app", options[:name]) if captured UI.important("no screenshots under #{FRAMED_DIR} - writing the text only") unless captured diff --git a/fastlane/screenshots/README.md b/fastlane/screenshots/README.md index b9f59bf2..c5a6f29b 100644 --- a/fastlane/screenshots/README.md +++ b/fastlane/screenshots/README.md @@ -29,11 +29,11 @@ Six screens per device, in store order. The edit is taken twice: | | | | --- | --- | | `01-browser` | the document browser, with one file of each format | -| `02-text` | a text document | +| `02-text` | a text document with search results | | `03-sheet` | a spreadsheet, with its sheet tabs | | `04-edit` | a document in edit mode, keyboard up | | `04-edit-lite` | the same, as Lite shows it: the Pro tools dimmed | -| `05-pdf` | a PDF, with a search under way | +| `05-pdf` | a PDF | | `06-office` | a Word file | Two devices: a 6.9" iPhone and a 13" iPad. `Fastfile` lists the simulator @@ -42,7 +42,7 @@ pixel sizes the store accepts. ## Locales -The store has eleven locales and the app has nine. `hi` and `sv` get the +Nine of the eleven store locales have app translations. `hi` and `sv` get the English pictures, because the app has no Hindi or Swedish UI. `scripts/store_screenshots.py --languages` prints the locales to capture. diff --git a/scripts/changelog_section.py b/scripts/changelog_section.py index a11c176c..0baaabd2 100755 --- a/scripts/changelog_section.py +++ b/scripts/changelog_section.py @@ -1,18 +1,11 @@ #!/usr/bin/env python3 -# -# Prints the CHANGELOG.md section for one version, and fails when there is none. -# -# The release run reads it before building, so a version without release copy -# fails in seconds rather than once both apps are uploaded, and again when it -# drafts the github release, whose body the section becomes. +# Print a version section from CHANGELOG.md; fail if missing or empty. import argparse import re import sys -# `## [1.37] - 2026-08-02`, `## [1.38]`, `## Unreleased`. Whatever follows the -# version is not looked at, so an unusual date separator still closes the -# section above it. Not `###`, which belongs to whichever section it sits in +# Match version headings at level two, allowing optional brackets and dates. HEADING = re.compile(r"^## +\[?([^\]\s]+)") # `[1.37]: https://github.com/...compare/1.36...1.37` at the foot of the file: diff --git a/scripts/frame-screenshots.py b/scripts/frame-screenshots.py index 415cf21c..b638512b 100755 --- a/scripts/frame-screenshots.py +++ b/scripts/frame-screenshots.py @@ -1,25 +1,6 @@ #!/usr/bin/env python3 -# -# Puts the captured screenshots into the picture the store shows: the app on a -# phone, on a coloured ground, under a line of copy in that locale's language. -# -# scripts/frame-screenshots.py frame the whole capture -# scripts/frame-screenshots.py --locale en-US one locale, for a look -# -# `fastlane ios screenshots` takes the raw captures into fastlane/screenshots; -# this reads them and writes the framed set to fastlane/framed, which is what -# `scripts/store_screenshots.py` then checks and stages. The raw set is left -# alone, so a framing change costs a rerun of this and not of the simulators. -# -# Nothing here is drawn from an image file. Every part of the design is a -# rounded rectangle, a plain rectangle or a line of text, so it is all in -# `fastlane/frames/frames.json` and in the numbers below - which is also what -# lets one canvas size become another. The only asset is the font. -# -# Needs Pillow, which is the one thing in this repository's scripts that is not -# in the standard library: -# -# python3 -m pip install Pillow +# Frame App Store captures from fastlane/screenshots into fastlane/framed. +# Requires Pillow. Use --locale to frame one locale. import argparse import bisect @@ -42,21 +23,7 @@ CAPTURED = ROOT / "fastlane" / "screenshots" FRAMED = ROOT / "fastlane" / "framed" -# The design, as fractions of the canvas rather than pixels, because the canvas -# is whatever the capture is - 1320x2868 today and something else after the next -# device. -# -# How far down something sits is a fraction of the height; how big it is, and -# how far across, is a fraction of the width. So a taller canvas gives -# everything more room without stretching any of it. -# -# The proportions came off the 2020 artwork at 1242x2208, where the phone was -# anchored by its top left corner and left to run off the bottom and the right. -# It stands whole now, as the Play listing's does: a device with its corners cut -# off reads as a picture that did not fit rather than as a phone. The canvas -# cannot grow to make room - App Store Connect takes the capture's own size and -# no other - so the device is fitted into it instead, which costs it some size -# and leaves ground under it. +# Layout dimensions are fractions of the canvas: y positions use height; sizes use width. LAYOUT = { "iphone": { "headline_top": 0.068, @@ -67,18 +34,13 @@ "screen_top": 0.265, "screen_width": 0.650, # as wide as it may be; `foot` is the other limit "foot": 0.045, # ground left under the device, of the height - # An iPhone 17 Pro Max, from its published dimensions: a 440pt screen - # inside a 78.0mm body, which leaves 2.54mm - 15.3pt - of black border - # and aluminium on every side, and a 62pt display corner. + # iPhone 17 Pro Max bezel and corner proportions. "bezel": 0.0272, # screen edge to the outside of the body "rim": 0.57, # how much of that is the black border "corner": 0.141, # screen corner, of the screen's width "island": (0.2826, 0.0811), # the Dynamic Island, of the screen's width "island_top": 0.0334, - # The action button and the two volume keys: (how far down the body, - # how long), both of the body's height. Measured off Apple's own bezel - # artwork, which is also where the bezel, the island and the corner - # come from - so all of it is the device rather than a guess at it. + # Left buttons as (top, height), relative to body height. "buttons": [(0.189, 0.0423), (0.262, 0.0686), (0.349, 0.0686)], # the side button, on the right edge "buttons_right": [(0.286, 0.1082)], @@ -89,34 +51,9 @@ "dash_stroke": 0.0056, "dash_on": 0.0236, "dash_off": 0.0098, - # Each decoration is a line that comes in from off the canvas, turns a - # corner and leaves again - one corner of a rounded rectangle far bigger - # than the picture. Points are (x, y) in canvas fractions, and a point - # past 1 is off the edge on purpose. A y of "chips" starts the line at - # the top of the tabs, so it runs behind however many there are and - # comes out underneath: anchored to a number instead, a screen with one - # tab leaves the line starting in mid air below it. - # The line crossing every screen. Each picture takes it in at the height - # the one before let it out at, steps it to a new height somewhere along - # the way, and hands it on - so no two screens carry the same line and - # the gallery still reads as one. There is one height per seam, which is - # one more than there are screens. - # - # Every height sits between the foot of the headline and the top of the - # device, the only band that is neither written on nor covered up. + # Gallery seam heights in canvas fractions; adjacent screens share a seam. "seams": [0.150, 0.200, 0.170, 0.215, 0.158, 0.195, 0.176], - # What the line does between the two seams it has to join. "in" is the - # height it arrived at and "out" the one it has to leave at; anything - # else is a height of its own. Only the left quarter is free below the - # band - the device covers the rest - so that is where a line can wander - # before it has to come back up and go. - # A step and, on some, a shallow dip into the left column before it. Kept - # shallow on purpose: the line is there to tie the pictures together, and - # one that wanders far down the page competes with what it is framing. - # One step, at a different place on each. Two of them dip a little - # further first, and only a little: the line is there to tie the - # pictures together, and anything more reads as decoration for its own - # sake across the top of every screen. + # Routes join the "in" and "out" seams; numeric heights are canvas fractions. "routes": [ [(0.34, "in"), (0.34, "out")], [(0.17, "in"), (0.17, "out")], @@ -126,10 +63,7 @@ [(0.15, "in"), (0.15, 0.238), (0.33, 0.238), (0.33, "out")], ], "radii": [0.078, 0.066, 0.086, 0.062, 0.072, 0.070], - # The lower line. Some of these hang off the tabs, some come in from the - # left edge instead - picking up where the picture before let its own - # line disappear behind the device - and one leaves to the left again. - # A screen with no tabs takes one of the ones that does not need them. + # Lower decorations attach to chips or enter from the canvas edge. "decorations": [ [("chips", "chips"), ("chips", 0.930), (0.55, 0.930)], [(-0.2, 0.700), (0.155, 0.700), (0.155, 0.930), (0.62, 0.930)], @@ -146,10 +80,7 @@ "screen_top": 0.250, "screen_width": 0.720, "foot": 0.055, - # An iPad Pro 13-inch, likewise: a 1032pt screen in a 215.5mm body is - # 8.44mm - 43.8pt - of border, near three times the phone's, and the - # display corner is 18pt where the phone's is 62. Nothing on the left - # edge either: its buttons are all on the top one. + # iPad Pro 13-inch bezel and corner proportions. "bezel": 0.0350, "rim": 0.86, "corner": 0.0174, @@ -189,9 +120,7 @@ BUTTON = ("#d8d8d6", "#a9a9a7", "#c4c4c2") GLASS = 96 # how brightly the screen's edge catches the light, of 255 -# The shadow the device casts. Black rather than a colour of its own, which was -# mixed for the green ground and went muddy on the orange one, and offset down -# and right instead of sitting square behind the body, where the body covers it. +# Black shadow, offset down and right. SHADOW = (0, 0, 0, 105) SHADOW_OFFSET = (0.30, 0.65) # of the bezel, across and down SHADOW_BLUR = 0.85 # of the bezel @@ -251,12 +180,7 @@ def rounded_path(points, radius, per_corner=24): def dashed(canvas, points, stroke, on, off, colour=(255, 255, 255, 255), phase=0.0): - """Lays dashes along a path, so a dash carries on around a corner. - - Counted out from the start of the path rather than accumulated as it walks, - because a step that rounds to nothing next to a distance already travelled - is a step that never arrives. - """ + """Draw dashes continuously around a path, measuring from its start to avoid rounding drift.""" reached = [0.0] for before, after in zip(points, points[1:]): reached.append(reached[-1] + math.hypot(after[0] - before[0], after[1] - before[1])) @@ -297,20 +221,12 @@ def at(distance): def squircle(box, radius, exponent=5.0, per_corner=40): - """A rounded rectangle whose corners are superellipse quadrants. - - Apple's corners are a continuous curve rather than a circular arc: the - curvature eases into the straight edge instead of starting at full bend. A - circle reads as an Android phone, or as a 2015 one. Exponent 5 is close - enough that nobody looks twice. - """ + """Return a rounded rectangle with superellipse corners.""" x0, y0, x1, y1 = box r = min(radius, (x1 - x0) / 2, (y1 - y0) / 2) points = [] - # each corner as (centre, x sign, y sign), going clockwise from bottom right. - # Two of the four are walked backwards, so that every quadrant leaves off - # where the next one starts and the outline closes. + # Walk superellipse corners clockwise, reversing alternate quadrants. for (cx, cy), sx, sy in (((x1 - r, y1 - r), 1, 1), ((x0 + r, y1 - r), -1, 1), ((x0 + r, y0 + r), -1, -1), ((x1 - r, y0 + r), 1, -1)): for step in range(per_corner + 1): @@ -325,13 +241,7 @@ def squircle(box, radius, exponent=5.0, per_corner=40): def outset(points, distance): - """The same outline, moved out by a fixed distance along its own normals. - - A squircle grown by raising its radius is not parallel to the one it grew - from: the gap between the two opens up around the corner and closes down - the sides. Drawn that way a bezel is visibly fatter at the corners than - along the edges, which is the first thing anybody who owns the phone sees. - """ + """Offset an outline along its normals to preserve a constant border width.""" walked = list(zip(points, points[1:] + points[:1])) facing = 1.0 if sum(x0 * y1 - x1 * y0 for (x0, y0), (x1, y1) in walked) > 0 else -1.0 moved = [] @@ -355,13 +265,7 @@ def stencil(size, points, supersample=3): def chamfer(share): - """The metal's colour that far across the band, outside edge to black. - - Read off Apple's own bezel: the edge is turned, so it comes in at a middling - grey, climbs to a highlight about two thirds of the way in and falls away - again. Lit along its length like this it reads as a rolled edge; filled - flat, as a grey stripe. - """ + """Return the metal color across the bezel, from outside edge to black.""" stops = ((0.00, 109), (0.19, 157), (0.40, 190), (0.64, 235), (0.79, 195), (1.00, 120)) place = bisect.bisect_right([at for at, _ in stops], share) if place == 0: @@ -412,26 +316,14 @@ def walked(points): def phone(canvas, shot, layout): - """The device: the capture behind glass, in a metal body. - - Drawn rather than pasted from a mockup, so it is the shape of whatever was - captured. A frame downloaded for one device is the wrong shape for the next - one, and the sets on offer stop at a generation the store no longer asks for. - - Built in its own image and composited once, so the parts can be masked - against each other without the ground showing through the seams. - """ + """Composite a capture inside a device frame, with bezel, buttons and shadow.""" width, height = canvas.size bezel = layout["bezel"] * width # screen edge to the outside of the body rim = bezel * layout["rim"] # how much of that is metal left, top = layout["screen_left"] * width, layout["screen_top"] * height - # Two limits rather than one fraction: `screen_width` is as wide as it may - # be, and `foot` is how much ground has to be left under it. Sized by the - # fraction alone, a device a little taller than the one the number was picked - # for runs its bottom rim off the canvas and a shorter one leaves a stripe of - # ground - neither of which is a decision anybody made. + # Fit the device within screen_width while preserving the foot margin. standing = (height - layout["foot"] * height) - top - bezel screen_width = min(layout["screen_width"] * width, standing * shot.width / shot.height) screen_height = screen_width * shot.height / shot.width @@ -463,15 +355,11 @@ def phone(canvas, shot, layout): radius=stand * 0.55, fill=255) device.paste(metal, (0, 0), buttons) - # Every edge is the screen's own outline moved out, so the black border and - # the metal around it are the same width the whole way round - which is what - # they are on the device, and not what a bigger squircle would give. + # Offset the screen outline to keep the bezel width constant. face = squircle(here(screen), corner) outline = outset(face, bezel) - # The metal, lit across the band's own width rather than the body's: the - # band is filled as rings, each the colour ``chamfer`` gives for how far in - # it sits, so the highlight follows the edge the whole way round. + # Shade concentric bezel rings with chamfer(). band = bezel - rim lit = Image.new("RGB", size, chamfer(1.0)) rings = ImageDraw.Draw(lit) @@ -492,10 +380,7 @@ def phone(canvas, shot, layout): (round(inside[0]), round(inside[1]), round(inside[0]) + fitted.width, round(inside[1]) + fitted.height))) - # The hairline where the glass meets the surround, which a real device - # catches the light along. Without it a screen that is dark at the top - the - # intro is - runs into the black bezel, and the two read as one fat border - # off a phone from 2016. + # Highlight the glass edge to separate dark screens from the bezel. hair = max(1.0, screen_width * 0.0012) halo = ImageChops.subtract( stencil(size, face), stencil(size, outset(face, -hair)) @@ -521,12 +406,7 @@ def phone(canvas, shot, layout): def headline(canvas, lines, layout): - """Two lines, light over bold, centred and shrunk until they fit. - - Fitted rather than set at a fixed size because the same sentence is a third - longer in German than in English, and a line that runs off the picture is - worse than one set a little smaller. - """ + """Fit two centered headline lines to the available width.""" width, height = canvas.size size = round(layout["headline_size"] * width) allowed = layout["headline_width"] * width @@ -553,14 +433,7 @@ def chips(canvas, names, palette, layout): face = font(round(layout["chip_text"] * width), "Bold") draw = ImageDraw.Draw(canvas) - # As wide as the longest word in the whole design needs, and no narrower - # than the design's own tab: "odt" is three letters and "docx" is four, and - # a tab cut to fit the shorter one loses the end of the longer. - # - # Measured across every format rather than the two or three on this screen, - # so the tabs are one length through the gallery. Cut to fit each screen - # they step in and out as the reader swipes, which reads as the pictures - # having been made at different times. + # Size all chips to the widest format name, keeping their width consistent across screens. padding = layout["chip_text"] * width * 0.42 chip_width = max([least] + [draw.textlength(name, font=face) + 2 * padding for name in palette]) @@ -586,9 +459,7 @@ def frame(shot, screen, locale, spec, order=0): stroke = layout["dash_stroke"] * width radius = layout["radii"][order % len(layout["radii"])] * width - # The crossing line, and the dash pattern picked up where the screens before - # it left off, so the dashes carry on across the gallery rather than - # restarting at every picture. + # Continue the dash phase from preceding screens. before = sum(walked(crossing(layout, index, shot.size)) for index in range(order)) dashed( canvas, rounded_path(crossing(layout, order, shot.size), radius), stroke, on, off, phase=before diff --git a/scripts/make-screenshot-documents.py b/scripts/make-screenshot-documents.py index 8ba16918..988e935a 100755 --- a/scripts/make-screenshot-documents.py +++ b/scripts/make-screenshot-documents.py @@ -1,23 +1,7 @@ #!/usr/bin/env python3 -"""Builds the documents the App Store screenshots are taken of. +"""Generate reproducible localized samples for Debug screenshot builds. -A reader's screenshots are mostly the document it is reading, so these are -written rather than borrowed: a short report, a small spreadsheet and a three -slide deck, in every language the app speaks and the store has a listing for. -The screenshot of the German store shows a German document. - -Kept small on purpose - a few kilobytes each, no images, no third party -material - because they are read once, on a simulator, to be photographed. -They are bundled in Debug builds only; `EXCLUDED_SOURCE_FILE_NAMES` keeps -`sample-*` out of the archive that goes to the store. - - python3 scripts/make-screenshot-documents.py - python3 scripts/make-screenshot-documents.py --language en one of them - -What it writes is not committed - the screenshot lane runs this before it -builds. The packages are byte for byte reproducible, so a rerun that changes -no wording writes the same bytes. -""" +Usage: python3 scripts/make-screenshot-documents.py [--language en]""" import argparse import json @@ -53,11 +37,7 @@ """ -# A4 upright for the report and the sheet, 16:9 for the deck. -# -# A page is fitted to the width of the screen, so a page with little on it reads -# as a smudge in the top third of an empty sheet. The answer is words rather -# than smaller paper: these documents are written long enough to fill A4. +# A4 portrait for documents; 16:9 for slides. PAGE_LAYOUTS = { "document": '', @@ -69,9 +49,7 @@ ACCENT = "#1c6fd6" RULE = "#d4d9e0" -# Which section of the report the figures sit under - the costs one, second of the -# five - and how many rows of them there are. The rows are what carry the report -# past the foot of a phone screen, so this is the number to turn if it stops. +# Place the cost table under the second report section. COSTS_SECTION = 1 REPORT_ROWS = 26 @@ -113,15 +91,7 @@ def paragraph_style(name: str, *, size: str, weight: str = "normal", colour: str def report(words: dict) -> str: - """A title, a lead, headed sections with the costs figures under theirs, and a - closing line. - - Long on purpose. A page fitted to the width of a phone is about two thirds of - its height, so a document that ends after one is photographed with a third of - the screen showing the backdrop behind it. Which is why there is a table in - here at all: the figures are the only length the report can be given that is - already written in all nine languages. - """ + """Build a report with headings and a cost table long enough to fill the screenshot.""" automatic = "\n".join( [ paragraph_style("Title", size="26pt", weight="bold", space="0.8cm"), @@ -170,15 +140,7 @@ def report(words: dict) -> str: def table(words: dict, columns: int = 4, rows: int = 0, scale: int = 1) -> tuple: - """The figures as rows of cells: a header, the items across as many periods - as are asked for with their totals, and a totals row under them. - - Narrowed and shortened for the files that are not the budget, so the .xlsx - and the invoice hold their own figures rather than the .ods twice. - - As wide as the header a language has words for, so one translated ahead of - the others comes out short rather than out of step. - """ + """Return header, data and totals rows for the requested periods and row count.""" periods = words["periods"][:columns] columns = len(periods) taken = FIGURES[:rows] if rows else FIGURES @@ -199,11 +161,7 @@ def table(words: dict, columns: int = 4, rows: int = 0, scale: int = 1) -> tuple def odf_row(cells: list, style: str | None = None) -> str: - """One row of an ODF table, as the report and the sheet both write it. - - A figure carries its value in the attribute as well as in the text, or the - spreadsheet holds a column of text that happens to look like numbers. - """ + """Build an ODF row with numeric cells carrying both values and display text.""" marked = f' table:style-name="{style}"' if style else "" out = [" "] for cell in cells: @@ -664,9 +622,7 @@ def deck(words: dict) -> str: }, } -# What the sheets add up: forty rows over six periods, so there is enough of it -# to look like a spreadsheet. The first twenty keep the first four figures they -# had, because the invoice and the .xlsx take slices off the front. +# Forty rows across six periods; other formats reuse smaller slices. FIGURES = [ [1200, 1450, 1310, 1600, 1380, 1520], [480, 620, 510, 470, 690, 540], @@ -710,9 +666,7 @@ def deck(words: dict) -> str: [740, 770, 800, 790, 830, 860], ] -# What the browser lists them as. Realistic rather than descriptive: the first -# screenshot is meant to look like somebody's folder, and the extensions do the -# talking about what the app opens. +# Localized sample filenames. FILE_NAMES = { "en": {"text": "Quarterly report", "sheet": "Budget", "slides": "Project plan", "word": "Contract", "cells": "Sales figures", "deck": "Team offsite", @@ -743,9 +697,7 @@ def deck(words: dict) -> str: "paper": "Fatura", "rows": "Kişiler", "notes": "Notlar"}, } -# The rest of the folder, so it does not read as a set of nine samples. Each is -# a copy of the sample named beside it, which only decides its icon: the browser -# shows a name and an icon, and none of them is ever opened. +# Extra browser entries copy existing samples and are never opened. FILLERS = { "meeting": "text", "letter": "text", @@ -874,12 +826,7 @@ def deck(words: dict) -> str: } -# The word the search screenshot looks for. -# -# Counted out of the document rather than written down, so it is always a word -# that is really in there, and always one that is in there several times - a -# search that highlights a single hit does not look like a search. Short words -# are skipped because "the" and "and" say nothing about the document. +# Choose a frequent long word for visible search matches. def query(words: dict) -> str: """The most repeated long word of the report, which is what to search for.""" text = " ".join( @@ -897,10 +844,7 @@ def query(words: dict) -> str: return max(counted, key=lambda word: (counted[word], len(word))) -# The folder is not nine copies of one report. What each file is called says -# what it should hold, so the contract reads like a contract and the invoice -# like an invoice - a folder where every document has the same title is the one -# thing a picture of a folder must not be. +# Distinct sample content for each document format. OTHERS = { "en": { "contract": ["Service agreement", @@ -1097,15 +1041,7 @@ def query(words: dict) -> str: PEOPLE = ["A. Bauer", "M. Rossi", "J. Novak", "L. Dubois", "S. Meyer", "K. Larsen"] -# --- the other formats ------------------------------------------------------ -# -# The first screenshot is a folder, so the folder has to look like somebody's. -# What it holds is the quiet half of the message: an .odt beside an .xlsx beside -# a .pdf says what the app opens without a line of copy claiming it. -# -# These are written small and plain for the same reason the ODF ones are. They -# are read by odrcore, not by Word, so they carry the least markup that is still -# a valid package. +# Minimal OOXML packages for screenshot samples. OOXML_RELS = """ @@ -1115,11 +1051,7 @@ def query(words: dict) -> str: WORD_MAIN = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" -# How many lines the contract's appendix lists, and the half-points the whole of it -# is set in. Between them they are what carries it past the foot of a phone screen, -# the clauses above being fourteen short sentences - and they are the only length -# there is to give it, the renderer honouring neither `w:spacing` on a paragraph nor -# `w:trHeight` on a row. +# Appendix row count and text size in half-points. ANNEX_ROWS = 40 CONTRACT_TEXT = 28 @@ -1161,9 +1093,7 @@ def run(text: str, *, size: int, bold: bool = False, colour: str = "") -> str: def para(runs: str, after: int) -> str: return f'{runs}' - # A clause a paragraph, numbered in line with its first word. A number on a - # line of its own above the sentence reads as a list of scraps rather than as - # a contract. + # Number each clause inline. paragraphs = [ para(run(title, size=72, bold=True), 640), para(run(lead, size=CONTRACT_TEXT), 420), @@ -1177,14 +1107,10 @@ def para(runs: str, after: int) -> str: ) ) - # An empty paragraph between them, rather than trusting w:spacing: the - # renderer sets the clauses flush against each other whatever `w:after` - # says, and a contract whose clauses touch reads as one block of text. + # Use empty paragraphs because odrcore ignores w:spacing here. body = ''.join(paragraphs) - # The appendix the last clauses promise, and the length that carries the - # contract past the foot of the screen. Its two columns are words the report - # already has in every language, so it costs no translation. + # Reuse localized table labels for the contract appendix. _, rows, _ = table(words, columns=1, rows=ANNEX_ROWS) body += cell_rows( [[words["item"], words["total"]]] + [[line[0], str(line[-1])] for line in rows] @@ -1200,10 +1126,7 @@ def para(runs: str, after: int) -> str: '', "_rels/.rels": OOXML_RELS.format(type=WORD_MAIN, target="word/document.xml"), - # Not optional. odrcore opens /word/styles.xml whether or not the - # document has a style in it, and a package without one is not read as a - # Word file at all: it falls through to the web view, which draws the - # text with no page around it and offers neither search nor editing. + # odrcore requires word/styles.xml even when no named styles are used. "word/_rels/document.xml.rels": OOXML_RELS.format( type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles", target="styles.xml", @@ -1220,9 +1143,7 @@ def para(runs: str, after: int) -> str: "", "word/document.xml": '' '' - # A4 with 2cm margins, in twentieths of a point. Without it there is no - # page for odrcore to lay the text on, and the document is drawn as a - # bare column of text rather than as a sheet of paper. + # A4 with 2 cm margins, measured in twentieths of a point. f"{body}" '' '' @@ -1336,10 +1257,7 @@ def shape(identifier: int, name: str, box: str, lines: list, size: int) -> str: } -# Helvetica's own character widths, in thousandths of the point size, so the pdf -# can be set the way a real one is: each word placed where it belongs rather -# than a whole line handed over as one run. It is also what lets the lines wrap -# where the text actually reaches the margin. +# Helvetica character widths in thousandths of a point. HELVETICA = { "regular": ( "278 278 355 556 556 889 667 191 333 333 389 584 278 333 278 278 " @@ -1366,11 +1284,7 @@ def shape(identifier: int, name: str, box: str, lines: list, size: int) -> str: def advance(text: str, weight: str, size: float) -> float: - """How wide that text is set in Helvetica at that size. - - An accented letter is as wide as the letter it is built on - true across - Helvetica's Latin range - so the table only has to hold the plain ones. - """ + """Measure Helvetica text in points, approximating accented letters with their base widths.""" table = WIDTHS[weight] total = 0 for character in text: @@ -1387,20 +1301,7 @@ def advance(text: str, weight: str, size: float) -> float: def spellable(words: dict, others: dict) -> bool: - """Whether Helvetica's encoding can write everything the invoice puts on the page. - - Everything, not a line or two of it: this used to read the title and the - closing, which is a sample rather than an answer - a language those two happen - to be spellable in can still hold a character further down that the encoding - has no byte for, and that character reaches the page as mojibake. The forty - rows the invoice bills for are forty more chances of that than it had. - - Three of the nine languages fail this and take the English invoice: pl and tr - for a handful of letters, ru for its whole script. Fixing that means embedding - a subset of a real font and writing the text as CIDs, which is a job of its own - and not one to do inside a screenshot script - so it is written down here - rather than left to be discovered in the store. - """ + """Check all invoice text against WinAnsi; unsupported locales use English.""" spoken = [words["item"], words["total"], words["title"], words["closing"]] spoken += words["periods"] + words["rows"] + others["invoice"] @@ -1411,23 +1312,12 @@ def spellable(words: dict, others: dict) -> bool: PAGE = (595.0, 842.0) MARGIN = 57.0 -# How many lines the invoice bills for. Enough to run onto a second page, for the -# reason `report` gives: a page is two thirds of a phone's height, and what fills -# the rest is the top of the page after it. +# Enough invoice rows to span two pages. INVOICE_ROWS = 40 def pdf_bytes(words: dict, others: dict) -> bytes: - """A PDF written out by hand rather than through a library. - - An invoice, which is a page of placed labels and figures rather than of - running prose: every cell is set where it belongs, so nothing has to be - wrapped and `advance` is only asked how wide a number is. - - Helvetica and WinAnsi, so what it says is Latin text only - the languages - this cannot spell get the English wording, which is also what the search - screenshot then looks for. - """ + """Build a paginated Helvetica invoice PDF, falling back to English for unsupported characters.""" latin = spellable(words, others) said = words if latin else WORDS["en"] @@ -1445,9 +1335,7 @@ def literal(text: str) -> str: pages = [[]] def put(text, x, y, weight="regular", size=10, align="left"): - """One line, placed on whichever page is open. Numbers are hung off the - right, which is what makes a column of figures a column rather than a - ragged list.""" + """Place a line on the current page, optionally aligned right.""" name = "F2" if weight == "bold" else "F1" at = x - advance(text, weight, size) if align == "right" else x pages[-1].append(f"BT /{name} {size:g} Tf {at:.1f} {y:.1f} Td ({literal(text)}) Tj ET") @@ -1477,10 +1365,7 @@ def put(text, x, y, weight="regular", size=10, align="left"): amount = line[-1] y -= 15 - # A line that would be set in the bottom margin opens the next page - # instead, with the column heads written again above it - which is what a - # producer does, and what makes the last page short rather than the first - # page overfull. + # Start a new page and repeat column headers before reaching the bottom margin. if y < MARGIN + 80: pages.append([]) y = PAGE[1] - MARGIN - 26 diff --git a/scripts/make-test-fixtures.py b/scripts/make-test-fixtures.py index 5cc7ca38..e06bfd34 100644 --- a/scripts/make-test-fixtures.py +++ b/scripts/make-test-fixtures.py @@ -1,12 +1,5 @@ #!/usr/bin/env python3 -"""Builds the minimal ODF packages the page selection tests translate. - -A few hundred bytes each, rather than the megabytes of third party material our -sample documents are, because all a test asks of them is how many pages a two -sheet spreadsheet turns into. Rerun when a fixture needs another sheet or slide: - - python3 scripts/make-test-fixtures.py -""" +"""Generate minimal ODF, PDF and archive test fixtures. Requires qpdf for encrypted PDFs.""" import shutil import subprocess @@ -88,11 +81,7 @@ def presentation(slides: list[str]) -> str: def pdf(pages: list[str]) -> bytes: - """A page per string, each showing it in Helvetica. - - Assembled by hand rather than with a library, because the trailer carries the - byte offset of every object and nothing else about the file is hard. - """ + """Build a PDF with one Helvetica text line per page.""" font = 3 # 1 catalog, 2 pages, 3 font, then a page and its content stream each page_numbers = [4 + 2 * index for index in range(len(pages))] @@ -133,11 +122,7 @@ def pdf(pages: list[str]) -> bytes: def encrypt_pdf(source: Path, target: Path) -> None: - """AES-256, the standard security handler odrcore reads as `V 5`, `R 6`. - - Through qpdf rather than by hand: AES is not in the standard library, and - R6 derives its key with it. Needs `brew install qpdf` to rerun. - """ + """Encrypt the fixture with qpdf using AES-256 (V 5, R 6).""" if shutil.which("qpdf") is None: raise SystemExit("qpdf not found; it writes the encrypted pdf fixture") diff --git a/scripts/resolve-version.py b/scripts/resolve-version.py index 14c0199f..203048db 100755 --- a/scripts/resolve-version.py +++ b/scripts/resolve-version.py @@ -1,19 +1,6 @@ #!/usr/bin/env python3 -# -# Resolves the version a release run builds: the dispatch input, or nothing at -# all on a dry run, which builds the 0.0.0 in project.pbxproj. -# -# The version comes from that input rather than a tag the run was pushed on: a -# tag would be a promise made before the upload, and a version often takes more -# than one build to clear review. The workflow writes the tags afterwards. -# -# The shape is checked here because xcodebuild never checks it: MARKETING_VERSION -# is a free-form string to the build, so a typo would only surface when App Store -# Connect rejects the upload at the very end. Whether the version is above what -# is live is left to the store, which is the only thing that knows. -# -# Writes the resolved version to GITHUB_OUTPUT as `version`, empty when there is -# none. Run it by hand to see what a dispatch would build. +# Validate the release version and write it to GITHUB_OUTPUT. +# A dry run may omit the version and build the project default. import argparse import os diff --git a/scripts/store-copy.py b/scripts/store-copy.py index 1091925e..4500970a 100755 --- a/scripts/store-copy.py +++ b/scripts/store-copy.py @@ -1,28 +1,6 @@ #!/usr/bin/env python3 -# -# Writes the store copy of one release: the English "What's New" text, and one -# translation per locale the listing has. -# -# scripts/store-copy.py 1.41 write whatever is missing -# scripts/store-copy.py 1.41 --english rewrite the English too -# scripts/store-copy.py 1.41 --dry-run print it, write nothing -# -# One `claude -p` per language rather than one call holding all of them. Each -# agent is given that locale's own description.txt and the notes of the release -# before it, so it reaches for the words the listing already uses in that -# language instead of translating the English afresh every release. They run at -# the same time, and a language that comes back wrong is retried on its own. -# -# A second agent then reads the draft against the English, in the same language, -# and rewrites what reads as English wearing that language's words. `--no-review` -# skips it. -# -# The English is written from the CHANGELOG.md section of that version, or from -# Unreleased while the heading is still open. A file that is already there is -# left alone and translated, since that is the copy that was reviewed. -# -# Nothing here uploads: `scripts/store_listing.py` checks and stages what this -# writes, and the release run uploads it. +# Generate and review localized release notes without uploading them. +# Usage: scripts/store-copy.py VERSION [--english] [--dry-run] [--locales LOCALES] import argparse import concurrent.futures @@ -240,9 +218,7 @@ def english(version, model, attempts): def translate(locale, version, source, model, attempts, review=True): - # the description is shown to the agent as the app already speaks that - # language, so the ${...} an app fills in comes out rather than being read - # as something the listing says + # Remove listing placeholders before supplying translation context. path = listing.METADATA / locale / "description.txt" description = listing.fill_in(path.read_text(encoding="utf-8"), [], where=path.name).strip() @@ -262,9 +238,7 @@ def translate(locale, version, source, model, attempts, review=True): if not review: return draft - # A second reader of the same language, because what a first draft gets - # wrong is not something the draft can see: a word borrowed for its sound - # rather than its sense reads fine to whoever wrote it. + # Review each translation against the English text. return produce( REVIEW_PROMPT.format( app=APP, diff --git a/scripts/store_listing.py b/scripts/store_listing.py index f8a031cc..14779058 100755 --- a/scripts/store_listing.py +++ b/scripts/store_listing.py @@ -1,32 +1,6 @@ #!/usr/bin/env python3 -# -# The App Store listing: where it is kept, and the deliver tree built out of it. -# -# App Store Connect keeps only the notes of the submission in flight, so the -# history it throws away is kept here instead: one file per locale per marketing -# version, `fastlane/metadata//changelogs/1.41.txt`. -# -# deliver reads none of that. It reads `release_notes.txt` beside it, one per -# locale, and uploads every metadata file it finds - so it is pointed at a -# staged directory rather than at fastlane/metadata itself, and what is copied -# in is named here rather than being whatever happens to be lying around. -# -# scripts/store_listing.py --version 1.41 check the notes -# scripts/store_listing.py --version 1.41 --stage DIR notes alone -# scripts/store_listing.py --version 1.41 --stage DIR --app pro the whole listing -# -# The two apps share one listing and differ in a few places, so what is staged -# is read in three passes - `fastlane/metadata//`, then the app's own -# `all/`, then its `/` - and the last one to hold a file wins. A -# `${name}` left in any of that text is filled in the same way, from the app's -# `name.txt`, or with nothing where the app has none. -# -# A release run checks before it builds, so a version missing a translation -# fails in seconds rather than once both apps are uploaded. Which languages -# there are, and which files a listing cannot be without, are written down below -# rather than counted up from what is on disk: either going missing should stop -# the release, not leave that storefront quietly as the console had it. -# `scripts/store-copy.py` writes the release notes this reads. +# Validate and stage versioned store notes and app-specific listing metadata. +# Usage: scripts/store_listing.py --version VERSION [--stage DIR --app pro|lite] import argparse import os @@ -37,16 +11,11 @@ ROOT = Path(__file__).resolve().parent.parent METADATA = ROOT / "fastlane" / "metadata" -# What each app says instead. `/all/` is read into every locale, `/de-DE/` -# into that one - so a name that is the same in every language is one file, and a -# sentence that has to be translated is eleven. +# Apply app-wide overrides before locale-specific overrides. APPS = ("pro", "lite") EVERY_LOCALE = "all" -# The languages the store sells the app in. Written down rather than counted up -# from whatever directories are there: a locale that loses its description, or -# its directory altogether, would otherwise drop out of the check and out of the -# upload alike, and the release would pass without ever mentioning it. +# Require every supported storefront locale, even if its directory is missing. LOCALES = ( "de-DE", "en-US", @@ -64,15 +33,7 @@ # what App Store Connect takes in one locale's "What's New" LIMIT = 4000 -# The text of the listing, per locale, as deliver names it. Listed rather than -# globbed so that adding a file here is a decision: everything in this set is -# pushed over whatever App Store Connect currently says. -# -# What a listing cannot be without. None of the three passes holding one of these -# is an error rather than a file left out of what is staged: deliver reads a -# field it was not given as "leave this be", so the console would keep the old -# words through the one run meant to replace them. The name is the easiest to -# lose - each app says it in a single file, for all eleven languages at once. +# Required deliver metadata fields; missing fields would retain stale store values. REQUIRED = ( "name.txt", "subtitle.txt", @@ -93,14 +54,9 @@ # Attached to the version rather than to a locale. NON_LOCALISED = ("copyright.txt",) -# Left behind deliberately, though deliver would take them: `review_information` -# is the account's own contact details and the note to the reviewer, and the -# category files say where the app sits in the store. Neither is release copy, -# and a release is a poor moment to discover either had drifted. +# Keep review contact details and store categories out of listing uploads. -# What App Store Connect refuses, rather than truncates. Checked against what is -# staged, since that is what goes up - a name is short enough on its own and too -# long once an app has added a word to it. +# Validate final text after app overrides and placeholders are applied. LIMITS = { "name.txt": 30, "subtitle.txt": 30, @@ -161,9 +117,7 @@ def sources(app, locale, metadata=METADATA): def read(name, places): - """The last of `places` to hold `name`, or None. An empty file does not count: - deliver reads one as "leave this be" rather than as "clear it", so a blank - override would silently be no override at all.""" + """Read the last nonempty override; deliver treats empty fields as unchanged.""" found = None for place in places: path = place / name @@ -216,12 +170,7 @@ def collect(version, metadata=METADATA): def stage(texts, directory, app=None, metadata=METADATA): - """Write the metadata tree deliver uploads. - - The release notes of this version always. With `app`, the rest of the listing - text beside them, as that app says it - which is what makes this repository, - rather than App Store Connect, the place the listing is written. - """ + """Stage version notes and, when app is set, its complete listing metadata.""" directory = Path(directory) if app and app not in APPS: raise ValueError(f"no such app: {app} - one of {', '.join(APPS)}") diff --git a/scripts/store_screenshots.py b/scripts/store_screenshots.py index f125846f..bd8fac3d 100755 --- a/scripts/store_screenshots.py +++ b/scripts/store_screenshots.py @@ -1,20 +1,6 @@ #!/usr/bin/env python3 -# -# The App Store screenshots: which ones there are, and the deliver tree built -# out of what a capture run wrote. -# -# Unlike the store copy, these are not committed. A picture of the app is only -# worth as much as the app it was taken from, so they are taken during the -# release run, from the build going out, and handed to deliver from there. -# `fastlane ios screenshots` takes them; this says what a full set is. -# -# scripts/store_screenshots.py --languages what to capture -# scripts/store_screenshots.py check what was captured -# scripts/store_screenshots.py --stage DIR --app A check it and stage A's set -# -# The store has eleven locales and the app is translated into nine of them. -# The other two get the English pictures, which is what their storefront would -# show anyway: the app has no UI in Hindi or Swedish either. +# Validate screenshot locales, screens and sizes; optionally stage them for deliver. +# Usage: scripts/store_screenshots.py [--languages] [--screenshots DIR] [--stage DIR --app APP] import argparse import os @@ -26,10 +12,7 @@ ROOT = Path(__file__).resolve().parent.parent SCREENSHOTS = ROOT / "fastlane" / "screenshots" -# Store locale -> the language the app is in when it is photographed for it. -# `None` means the app has none, so that locale reads the English pictures. -# The keys are the locales `fastlane/metadata` has; the values are what iOS -# resolves the locale to, which is why de-DE is one folder and pt-BR is another. +# Map store locales to app languages; None uses English screenshots. LOCALES = { "de-DE": "de", "en-US": "en", @@ -57,22 +40,17 @@ "06-office", ) -# What Lite shows in place of a screen of Pro's. The edit is the one screen -# where the two apps differ: Lite dims the Pro tools behind a badge. +# The Lite screen that replaces a Pro screen: Lite dims the Pro edit tools. LITE = { "04-edit": "04-edit-lite", } APPS = ("pro", "lite") -# Every picture a run takes, longest name first, so a name that ends another -# one is never matched in its place. +# Longest name first, so a name that ends another one never matches in its place. CAPTURED = tuple(sorted(SCREENS + tuple(LITE.values()), key=len, reverse=True)) -# What App Store Connect accepts, upright, in pixels. An app that runs on both -# has to hand in both, and the store fits every smaller iPhone and iPad from -# these two. More than one size per device because which simulator a runner has -# depends on the Xcode it is running. +# Accepted portrait screenshot sizes, grouped by device. SIZES = { "iphone": { (1320, 2868), # 6.9", iPhone 16 Pro Max and later @@ -118,13 +96,7 @@ def device(width, height): def collect(directory): - """What one capture run wrote. Returns (files by locale and device, problems). - - snapshot names its output `-.png`, one folder per - language, so the simulator's name is read off the front and the size decides - which device it counts as - the name of a simulator changes with Xcode, the - number of pixels it has does not. - """ + """Collect screenshots by locale and device, returning files and validation errors.""" directory = Path(directory) found = {} problems = [] @@ -150,11 +122,7 @@ def collect(directory): kind = device(width, height) if kind is None: - # Not ours to upload, and not a reason to stop: asked for one - # simulator, snapshot photographs every one whose name starts - # the same way, so a runner that has an iPhone 16 Pro as well as - # the Pro Max hands back a third set nobody asked for. What has - # to be there is still checked below, per device. + # Ignore extra simulator sizes; required devices are checked below. continue pictures.setdefault(kind, {})[screen] = path @@ -170,13 +138,9 @@ def collect(directory): def stage(found, directory, app): - """Write the screenshot tree deliver uploads for `app`. + """Stage screenshots of `app` for deliver, copying English images for fallback locales. - A folder per store locale, the borrowed ones copied from the English rather - than left out: what deliver does not upload for a locale, App Store Connect - keeps - which would be whatever was there before this release. Lite's - pictures go under the name of the screen they replace, so they keep its - place in the store. + Lite images take the name of the Pro screen they replace, so they keep its store position. """ directory = Path(directory)