diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md index 71465bf..14c574a 100644 --- a/.claude/skills/release/SKILL.md +++ b/.claude/skills/release/SKILL.md @@ -3,11 +3,12 @@ name: release description: >- Everything about shipping Tortoise Blocks and everything the public sees: cutting a v* tag, Xcode Cloud archives and TestFlight, the App Store Connect - listing in appstore/ and the fastlane lanes that push it, screenshots, - accessibility nutrition labels, bundle identifiers and build audience, and - the published website in site/ (the landing page and the privacy policy). - Load this before tagging a release, editing the store text or screenshots, - running fastlane, or touching site/. + listing in appstore/ and the fastlane lanes that push it, accessibility + nutrition labels, bundle identifiers and build audience, and the published + website in site/ (the landing page and the privacy policy). Load this before + tagging a release, editing the store text, running fastlane, or touching + site/. **Making** the screenshots is the `screenshots` skill; this one covers + pushing them. --- # Releasing Tortoise Blocks @@ -71,16 +72,10 @@ artwork, served from `site/` rather than Apple's CDN (a site that promises no tracking should make no third-party request), and its `href` is *the* single place the store URL lands: `apps.apple.com/app/id6798677334`, with no country code, so Apple sends each visitor to their own storefront. Its screenshots are downscaled -copies of `appstore/screenshots/`, quantized to 256 colors (`magick -dither None --colors 256 -strip`) and then run through `oxipng -o max --strip safe`: flat app -UI loses nothing visible and the page drops from ~4.7MB to ~1MB — but check a -re-quantized shot by eye, since dithering *on* leaves visible speckle in the -toolbar shadows. The oxipng pass is a third of that saving and it is *lossless*, -so it goes over the store captures and `docs/` too (18.6% off everything, pixels -proved identical by hashing the decoded images before and after). Without it, -`magick` alone lands about 25% high: the 1.0.0 files were plainly made with some -such pass, so a set regenerated on a machine that has none comes out heavier for -no visible reason. And each Japanese paragraph is one +copies of `appstore/screenshots/`, produced by `Tools/screenshots.rb` — see the +`screenshots` skill, which owns that pipeline and the reasons behind it; what +matters here is that the page weighs ~1MB rather than ~4.7MB because of it. +And each Japanese paragraph is one source line: a newline between two CJK characters is not reliably collapsed away, and shows up as a gap mid-sentence. @@ -96,7 +91,27 @@ number in the filename, and the sizes are the ones Apple accepts as-is (iPad 13-inch landscape 2752×2064, Mac 2880×1800), so a reshoot has to keep the window sizes that produced them. The two documents the captures were shot from sit in `appstore/screenshot-sources/`, deliberately *outside* `screenshots/`. -The text is `appstore/metadata//`, one file per field. + +**Making the captures is the `screenshots` skill** — the rigs +(`Tools/ipad-shots.rb`, `Tools/visionos-shots.rb`), the flatten-and-optimise +pass every reshoot has to end with, and the traps that produce a picture of the +wrong thing. Nothing reaches App Store Connect without going through it: every +source this project shoots from writes an alpha channel, which Apple refuses. +The text is `appstore/metadata//`, one file per field — **except +visionOS**, which is pushed from `appstore/metadata-visionos/` instead (#53). +That split is not tidiness: the App Store shows a Vision Pro shopper the +visionOS description and nothing else, and the app is a different product +there — a viewer for drawings made on iPad and Mac, with no editing in it at +all — so the shared description would open by telling that shopper to drag +blocks into a program, the one thing they cannot do. Three fields in those +directories are **app**-level in App Store Connect rather than version-level +(`name.txt`, `subtitle.txt`, `privacy_url.txt`), so every lane writes the same +ones and whichever runs last decides them for all three listings; they are kept +byte-identical between the two directories and `metadata_check` fails if they +drift. A platform's text is also the first thing to go stale when the app +changes shape: the visionOS copy described "the same three panes in a window" +for as long as visionOS was the iPad app in a window (#11), and stayed that way +through the rewrite that made it a viewer. **fastlane pushes it, and a self-written tool did not.** The uploader was designed as a zero-dependency Swift executable (#42) and abandoned about 900 @@ -153,11 +168,9 @@ same size as an Apple TV one, so deliver cannot resolve the display type from the size and falls back to asking whether the *path* contains `vision` (downcased) — name the directory after deliver's own platform value and every screenshot is filed as `APP_APPLE_TV` on an app with no tvOS listing. -The captures need no staging: `xcrun simctl io screenshot` on the -visionOS simulator writes exactly 3840×2160, the simulated room and all, which -is what visionOS screenshots look like anyway. They do carry an alpha channel, -so `-alpha off` applies here like everywhere else. And no new identifier is -needed — spaceship maps `xros` onto the **iOS** `BundleIdPlatform`, so the App +The captures come from the simulator at exactly 3840×2160, the simulated room +and all, which is what visionOS screenshots look like anyway (the `screenshots` +skill has the rig). And no new identifier is needed — spaceship maps `xros` onto the **iOS** `BundleIdPlatform`, so the App IDs the iPhone/iPad build already registered are the ones visionOS signs against. diff --git a/.claude/skills/screenshots/SKILL.md b/.claude/skills/screenshots/SKILL.md new file mode 100644 index 0000000..a59a914 --- /dev/null +++ b/.claude/skills/screenshots/SKILL.md @@ -0,0 +1,195 @@ +--- +name: screenshots +description: >- + Producing every picture of the app that ships: the App Store captures in + appstore/screenshots/ for iPad, Mac and Vision Pro, and the downscaled copies + the website uses in site/shots/. Covers the capture rigs + (Tools/ipad-shots.rb, Tools/macos-shots.rb, Tools/visionos-shots.rb), the + flatten-and-optimise pass + (Tools/screenshots.rb), what a sendable capture has to be, and the traps that + make a screenshot tool fail silently. Load this before reshooting, before + adding a shot or a platform, and whenever a capture looks wrong. +--- + +# Screenshots + +Three platforms, two languages, one pass at the end. **Every route ends in +`ruby Tools/screenshots.rb`**, and a green `appstore/ looks sendable.` is the +only evidence that counts. + +```bash +ruby Tools/ipad-shots.rb # iPad: 4 shots × 2 languages, ~8 min +ruby Tools/macos-shots.rb # Mac: 4 shots × 2 languages +ruby Tools/visionos-shots.rb # Vision Pro: 3 shots × 2 languages +ruby Tools/screenshots.rb # always: strip alpha, optimise, rebuild site/shots +``` + +All three take a name filter (`ruby Tools/ipad-shots.rb star`) and end by +calling `Tools/screenshots.rb` themselves. Build the scheme first; they install +whatever is in DerivedData. + +## What a capture has to be + +`fastlane/metadata_check.rb` enforces all of this, on every pull request, from +the files alone — so the answer to "is this sendable" is to run it, not to +look. + +- **Sizes**: iPad 13-inch 2752×2064 (or portrait), Mac 2880×1800, Vision Pro + 3840×2160. A size Apple does not accept is a mistake worth stopping on, not + a shape to guess at, so an unexpected one fails rather than being resized. +- **No alpha channel.** App Store Connect refuses one and says so only when the + submission is refused. +- At most ten per locale; order comes from the leading number in the filename. +- Directories are App Store Connect's own vocabulary — see the `release` skill, + which owns the listing side. + +## The flatten-and-optimise pass + +**Every source this project shoots from writes an alpha channel and none can be +told not to**: the iPad and Mac captures come out of XCUITest, the visionOS +ones from `simctl io`. The channel has always been fully +opaque, so `Tools/screenshots.rb` drops it losslessly — `-alpha off`, never a +composite. A capture with *real* transparency stops the run instead, because +choosing a background would change the picture and that is a person's decision. + +It then runs `oxipng -o max --strip safe`, which is lossless and worth about a +fifth of the bytes, and regenerates `site/shots/`. + +**That last part is the reason it is a script and not a paragraph.** The site +images are downscaled copies of seven captures, so a reshoot that stops at +`appstore/` leaves the website showing the previous build's UI. That was missed +twice before the script existed. + +Two things to expect. The site derivation is deterministic — rerunning it +against unchanged captures reproduces the committed files byte for byte, so a +no-op run leaves the tree clean. But `oxipng -o max` is *not* bit-for-bit +reproducible, so `--all` can rewrite an already-optimal capture by a few dozen +bytes with the pixels untouched; that is why the default run only touches what +it just flattened. + +It asks `MetadataCheck.alpha?` about the channel — the same predicate the CI +gate uses, so the fixer and the gate cannot disagree about what counts. + +### Adding a site image + +`DERIVED` in `Tools/screenshots.rb` maps capture → site file and size. It is a +table rather than a rule because the choice is curated: the code pane is on the +Mac half of the page and not the iPad half. A new site image is a new row. +There is no visionOS row yet. + +Quantizing to 256 colours is what makes the page ~1MB instead of ~4.7MB, and +flat app UI loses nothing visible — but check a re-quantized shot by eye, since +dithering *on* leaves visible speckle in toolbar shadows. + +## iPad — a UI test, not launch arguments + +`TortoiseBlocksUITests/ScreenshotTests.swift` does the pressing; +`Tools/ipad-shots.rb` holds the shot list and the plumbing. + +**Rotation is why it is a UI test.** `simctl` cannot turn an iPad, and driving +the Simulator's own menu means granting keystroke permission to whatever runs +the script. `XCUIDevice` rotates in a line, and the same mechanism then presses +play and switches panes — so the app carries no screenshot-only code. + +**Nothing matches on a label**, since both languages are shot from the same +code. What is stable is an element's type, its position, and — for the +transport — its **SF Symbol name**, which SwiftUI passes through as the +accessibility identifier (`play.fill`). + +Five failures, each of which produces a capture that looks perfectly well made: + +- **The scrubber is `Disabled` until a program has been run.** There is no + shortcut to the end of a drawing: dragging it, `adjust(toNormalizedSliderPosition:)` + and `⌘R` (`AppCommands`, and the simulator has no hardware keyboard) all do + nothing, silently, and hand back a picture of an empty canvas. Tap `play.fill` + and wait for the scrubber's accessibility value to stop changing. +- **`XCTAttachment(screenshot:)` writes the framebuffer as it is held** — + portrait — and leaves the rotation to a flag, so a landscape capture arrives + 2064×2752 on its side. Redraw the image once to bake it in. +- **Set the orientation after `launch()`.** Before it, the device comes back + portrait when the app arrives and the setting is silently undone. +- **`TEST_RUNNER_*` must be on xcodebuild's own environment.** Passed as + `KEY=value` arguments after the command they are accepted, ignored, and + arrive nowhere. +- **Seed documents into the device's `tmp`, not the app's container.** + Preparing a run reinstalls the app, and a reinstall gives it a new data + container, so anything seeded there beforehand is gone. + +Two more the driver handles. The app is **uninstalled before each group of +shots**: opening a document from outside the app's own folder imports a copy +under a deduplicated name, against a history that outlives deleting the files, +so the spiral came back titled `spiral-1` in its own title bar. And shots are +**grouped so each drawing is opened once per run**. The status bar is pinned to +**9:41** for the run — without it the captures carry whatever the clock said, +and a reshoot never matches the set it joins. + +Testing is non-parallel on purpose: a parallel run clones the simulator, and +the clone is not the device the documents were seeded on. + +## Vision Pro — launch arguments + +`-TBPlace` loads a sample and puts it down, `-TBSample` picks which, +`-TBDraw <0…1>` runs the drawing that far, `-TBSheet side,reach,drop` frames +it. Language comes from `-AppleLanguages` on the same line, so both locales +come out of one run with nothing left switched on the device. + +- **The simulator shows the drawing.** A long-standing note said it hosts no + `ViewAttachmentComponent`; it does. What was broken was placing *after* the + load, which flips `sitsOnTable` — the `.id()` on the immersive space's + `RealityView` — demolishing the scene at launch. +- **`-TBSheet` overrides `reach` and `floatingDrop`, not the position they + produce.** The simulator reports a usable head pose, so placement takes the + *aimed* branch exactly as a headset does; an override written against the + no-pose fallback compiles, runs, and does nothing. +- **The sheet is nearer than the windows**, so a paper wider than about 0.7m + occludes the blocks and code windows — which is the arrangement the captures + exist to show. Treat `side` as a ceiling. +- The app is **uninstalled before every capture**: visionOS restores windows, + so a launch otherwise inherits the last one's and opens its own on top — + two blocks windows, one of them near the ceiling. +- A run occasionally opens with **no sheet at all**. The app logs `TBReady` + once the canvas has attached, `TBNotReady` if it gives up, and the driver + relaunches rather than photographing an empty room. Waiting on `EntityLoad`, + which is only the USDZ arriving, files pictures of empty rooms. + +## macOS — a UI test and a plate + +`Tools/macos-shots.rb`, sharing `ScreenshotTests.swift` with the iPad. The +capture is the **window alone** (`XCUIElement.screenshot()`); the desktop and +menu bar around it come from `appstore/screenshot-sources/macos-plate-{en,ja}.png`, +drawn once per language. That is what keeps the picture independent of the +machine — no wallpaper, menu extra or clock of the Mac's own reaches it. + +**The plates carry no shadow.** It is generated at composite time, so the +window can move or change size without the artwork being redrawn. The window +is centred under the menu bar and fully in frame. + +**macOS UI testing needs Xcode to hold the Accessibility permission** (System +Settings ▸ Privacy & Security ▸ Accessibility). Without it every run fails +with "Timed out while enabling automation mode", which mentions neither Xcode +nor permissions. + +Four ways the Mac differs from the iPad, all handled but all worth knowing: + +- **The window screenshot is fully opaque, with the rounded corners filled + near-black.** Composite it as-is and the window wears four black wedges. The + corners are flood-filled to transparent rather than masked with a drawn + radius — the shape is macOS's own continuous curve, not a circle. +- **macOS reopens the windows it had when it quit**, so the second shot's + launch restores the first shot's drawing and opens its own beside it. Two + windows means two transports, and `play.fill` stops being a single element: + "Multiple matching elements found", which says nothing about restoration. + Every window is closed after the launch. +- **The same `Picker(.segmented)` is a different element**: a + `SegmentedControl` of buttons on iOS, a `RadioGroup` of radio buttons in the + toolbar on macOS. +- **The saved window state is thrown away before each run.** macOS restores a + window's frame in preference to the app's `defaultSize`, and that default is + what makes 1280×800pt — 2560×1600px — reproducible. Keep that `defaultSize`: + a capture at any other size would need cropping or resampling. + +## Judging the result + +Look at the pictures. `metadata_check` proves a capture is *sendable*, not that +it shows the right thing — every failure listed above passes it. The rigs +report what they filed; open the files. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b168396..3245e83 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,7 +65,8 @@ jobs: - name: swift format lint run: | swift format lint --strict --parallel --recursive \ - App ThumbnailExtension TortoiseBlocksKit/Sources TortoiseBlocksKit/Tests + App ThumbnailExtension TortoiseBlocksUITests \ + TortoiseBlocksKit/Sources TortoiseBlocksKit/Tests swift format lint --strict TortoiseBlocksKit/Package.swift kit-test: diff --git a/App/TortoiseBlocksApp.swift b/App/TortoiseBlocksApp.swift index 8872ab9..e881463 100644 --- a/App/TortoiseBlocksApp.swift +++ b/App/TortoiseBlocksApp.swift @@ -1,5 +1,34 @@ import SwiftUI +#if os(visionOS) + /// Which side of the remote a window opens on. + /// + /// An enum rather than passing `WindowPlacement.Position.leading` around: + /// those are static *methods* taking the window to be beside, so they + /// cannot be named without one, and the whole point here is to name the + /// side before the window is known to be open. + private enum WindowSide { + case leading + case trailing + } + + /// A placement beside the window with `id` — or the system's own choice + /// when there is no such window to be beside, which is what a restored + /// window meets when it comes back before the remote does. + private func placement( + _ side: WindowSide, of id: String, in context: WindowPlacementContext + ) -> WindowPlacement { + guard let relative = context.windows.first(where: { $0.id == id }) else { + return WindowPlacement() + } + + switch side { + case .leading: return WindowPlacement(.leading(relative)) + case .trailing: return WindowPlacement(.trailing(relative)) + } + } +#endif + @main struct TortoiseBlocksApp: App { // The viewer's whole state, shared by its two scenes (#53). One `Scene` @@ -21,7 +50,7 @@ struct TortoiseBlocksApp: App { // and that is what makes a visionOS build worth having at all instead // of an iPad app in a window. #if os(visionOS) - WindowGroup { + WindowGroup(id: ViewerModel.remoteWindowID) { ViewerWindow(model: viewer) } // The window is its contents, not a canvas they sit in — so no @@ -38,6 +67,22 @@ struct TortoiseBlocksApp: App { ProgramWindow(model: viewer) } .defaultSize(width: 480, height: 700) + // **Beside the remote, not on top of it.** Left the system opens + // every window in the same place — straight ahead — so asking for + // the program put it over the controls that asked, and asking for + // the code put it over both. Three surfaces at once is the entire + // argument for this platform (#53), and stacking them is the one + // arrangement that does not deliver it. + // + // Reading order decides which side: the blocks are what the + // drawing is made *from*, so they sit to the left of the remote, + // and the code — what the blocks become — to its right. On + // visionOS a placement can only name another window and a side; + // the absolute initialisers are all unavailable there, which is + // exactly enough for this and nothing more. + .defaultWindowPlacement { _, context in + placement(.leading, of: ViewerModel.remoteWindowID, in: context) + } // And the code on a third (#53 Phase 3). Same reasoning one step // further: iPad and Mac make the canvas and the code two states of @@ -49,6 +94,9 @@ struct TortoiseBlocksApp: App { // Wider than the program's 480: source lines are longer than block // rows, and the pane scrolls horizontally rather than wrapping. .defaultSize(width: 620, height: 700) + .defaultWindowPlacement { _, context in + placement(.trailing, of: ViewerModel.remoteWindowID, in: context) + } ImmersiveSpace(id: ViewerModel.spaceID) { TableCanvasSpace(model: viewer) diff --git a/App/Views/Viewer/CLAUDE.md b/App/Views/Viewer/CLAUDE.md new file mode 100644 index 0000000..abe1482 --- /dev/null +++ b/App/Views/Viewer/CLAUDE.md @@ -0,0 +1,168 @@ +# The visionOS viewer + +Everything in this directory is `#if os(visionOS)`. It is a different product +from the editor next door (#53): a viewer for drawings made on iPad and Mac, +with no editing in it at all — which is what lets the whole `DocumentGroup` +apparatus go, and is the reason a visionOS build is worth having instead of an +iPad app in a window. + +The scene tree that ties these together is in `App/TortoiseBlocksApp.swift`; +how the editor's own views look is `App/Views/CLAUDE.md`. + +**The 3D tortoise is generated, not modelled** (#53). +`App/Resources/Tortoise.usdz` comes out of `Tools/tortoise-model/build_tortoise.py`, +a Blender script whose constants *are* the three-view drawing's measurements, and +is checked in beside it so no build step needs Blender. **The reasoning for every +number is in `Tools/tortoise-model/README.md`**; what app code may assume is: + +- `upAxis = "Y"` with **forward at −Z** — wrong settings here are invisible until + the tortoise drives sideways. +- **Total length exactly 1.0** with `metersPerUnit = 1`. Normalised, not + real-world: the canvas is a 0.2–2m gesture and the size is computed anyway. +- **The origin is the ground point under the shell's centre** — the point it + turns about, not the brush tip, so the drawn line trails behind the animal. +- **Every material emits a third of its own colour.** A `.mixed` space lights the + model with the real room, and a lamp-lit evening one drained the pastels to mud + (luminance 39 of 255, gold reading brown; 111 with emission). Do not "fix" it as + a PBR error, and do not lighten the colours — they are sampled from the drawing, + which is the specification. + +It rides in `App/` as a synchronized-folder resource, landing flat at +`Contents/Resources/Tortoise.usdz` — verified in the built bundle, the only way +that works. Blender rendering it proves nothing about RealityKit; `qlcheck.swift` +runs it through Apple's own USD stack instead. + +**The tortoise on the table is drawn by us, and that took a library release** +(#53 Phase 3, TortoiseGraphics2 2.1.0). The sheet is still the app's own +`TortoiseCanvas` in a `ViewAttachmentComponent`; what changed is that it now +draws everything *except* the tortoise (`.tortoiseSprite(.hidden)`), and the +USDZ stands on the paper as a child of the sheet entity — so the pinch, twist +and drag it inherits for free, and its own transform only ever says where on +the page it is. Three upstream additions were needed and none of them had an +honest app-side substitute. `.hidden` is a property of the *view*, unlike +`hideTortoise()`, which records a command and would have followed the drawing +into the SVG, the PNG, the thumbnail and the saved file. +`TortoisePlayer.currentTortoiseState` is the pose **interpolated between +commands**: `currentCommandIndex` — what every other surface in the app watches +— changes about ten times a second, and a tortoise moved on that schedule +teleports from command to command while the line it is drawing grows smoothly +underneath it, which is the one thing this feature exists to show. And +`ViewportMode.transform` is public so the placement asks for `autoFit`'s +mapping rather than reimplementing it; a reimplementation agrees on the day it +is written and drifts silently after. It is read once per *display frame*, from +a `SceneEvents.Update` subscription — not from `body`, which would re-evaluate +the view at the refresh rate — and the subscription has to be retained +(`FrameTicker`), because one that nothing holds is cancelled at the end of +`make` and looks exactly like a handler that is never called. +Two numbers are judged on device and are the first things to change if it looks +wrong: the tortoise is `1/12` of the sheet's side (deliberately larger than the +2-D sprite's ~1/30 — on a screen it is a cursor, on a table it is the animal), +and the paper keeps a 64pt margin, since a hidden sprite earns no `autoFit` +inset and the drawing would otherwise run to the paper's edge with the tortoise +hanging off it. The lift onto the paper is *measured* from the loaded model, +not assumed: the feet reach ~6‰ of the body length below the origin, which is +the ground point under the shell's centre. +**The visionOS simulator shows all of this** — paper, drawing and tortoise — +and this note said the opposite for a while, which is worth keeping as a +correction rather than an edit. The symptom was real: a blank sheet, a nil +`currentTortoiseState`, a tortoise that never appeared. The diagnosis was not. +`ViewAttachmentComponent` hosts fine here; what was broken is that `-TBPlace` +used to place by calling `place(.inFront)` *after* the load, which flips +`sitsOnTable`, which is the `.id()` on the immersive space's `RealityView` — so +the scene was torn down and rebuilt at launch and the attachment did not come +back. Setting the preference before the load (which is what "opening a drawing +puts it down" made natural) leaves the id alone and the sheet renders. The +lesson generalises: `.id()` on a `RealityView` is a demolition order, and an +attachment that fails to return from one is indistinguishable from a platform +that never supported attachments. +**Screenshots are shot from the simulator, not a headset** — a room cannot be +framed the same way twice and is someone's home besides. The rig, its launch +arguments and its traps are the `screenshots` skill. One thing here is app +code rather than tooling: `-TBSheet` overrides `reach` and `floatingDrop`, +the two constants the *aimed* placement is built from, rather than the position +they produce. The simulator reports a usable head pose (`queryDeviceAnchor` +starts at the identity transform, which is what `minimumEyeHeight` guards +against, but `pose` retries for three seconds and gets a real one), so +placement takes the aimed branch exactly as a headset does — and an override +written against the no-pose fallback compiles, runs, and does nothing. + +**The viewer has three surfaces, and the third is the code** (#53 Phase 3). +Table, program, code — a `WindowGroup` each, all open at once. That is the +whole argument for the platform restated one step further: iPad and Mac make +the canvas and the code two states of *one toggle* because a window holds one +of them, and a headset never has to choose. The code window is `CodePane` +unchanged, which #11 had already made work here by taking it off +`.background.secondary` (translucent glass on this platform, with the syntax +colours left standing on nothing). The source is generated in +`ViewerModel.load` rather than in the window's `body`: the iPad's pane is only +in the hierarchy while its toggle says so, but a window redraws on its own +schedule and nothing here can edit the program behind it. +**The three windows open beside each other, not on top** (#53). Left the +system, every window opens in the same place — straight ahead — so asking for +the blocks put them over the controls that asked, and asking for the code put +it over both. Three surfaces at once is the entire argument for this platform, +and stacking them is the one arrangement that does not deliver it, so the +program and code groups carry `defaultWindowPlacement`: blocks to the remote's +`.leading`, code to its `.trailing`, reading order deciding which side, since +the blocks are what the drawing is made *from* and the code is what they +become. The remote therefore has an id of its own (`remoteWindowID`) — unusual +for an app's first window, and the only way `WindowPlacementContext.windows` +can be asked which one to sit beside. On visionOS a placement can name another +window and a side and nothing else: every absolute initialiser is +`@available(visionOS, unavailable)`. That is exactly enough here, and it is +also why this was nearly written off as impossible — the App Store captures +were going to be shot around the overlap before the API was checked. + +**The remote's controls are grouped by what they do, not by what they are.** +The row used to read 「つくえに おく」「ブロックを みる」「コードを みる」, whose +only shared property was being buttons — one placed the drawing, two opened +windows — while placement's own mode switch and reset sat in a *different* row +underneath with those two wedged between. Placement is now one group with its +own question over it, the other surfaces are another below a divider, and three +things fell out of doing it. The two verbs went: 「つくえに おく」 (put the +drawing down) and 「つくえに のせる」 (look for a table at all) were nearly the +same words for different things, invisible while they sat apart and unbearable +once grouped — so `ViewerModel.placing` names the **three** states the window +actually has (away / table / in front) and one picker asks them. It stays +read-only and the window drives it through an async action, because `isPlaced` +is only true once the space has really opened and a refused world-sensing prompt +must leave the picker showing where the drawing *is*. A visionOS **ornament** was the other candidate for those +two — the platform's own place for "belongs to this window but is not its +content" — and was turned down: it is always visible, so it hangs under the +window even in the small "えが ありません" state and adds its height to every +glance, and a divider already says the difference for nothing. The window +buttons became toggles, since `openWindow` on an open window only brings it +forward — a switch with one position — so the windows report themselves through +`isProgramWindowOpen` / `isCodeWindowOpen`, there being nothing in SwiftUI to +read that from. And floating stopped being an error: it can now be *chosen*, so +`PlacementStatus` says "no table found" only when a table was actually asked +for. + +**Opening a drawing puts it down**, and that is the placement group's last +open question answered. Choosing a file used to change nothing but the window: +the room stayed empty until the picker was touched, so the app read as one +that had not opened the file — the state with the least to look at was the one +reached by doing the thing the app is for. An alert asking "shall I put it on +the table?" was the obvious fix and is the wrong one twice over: the answer is +always yes, and the first placement already raises the world-sensing prompt, so +it would be two modals in a row before anything appeared. So a load *is* a +placement, through `ViewerModel.loadGeneration` — a counter rather than a flag, +because `blocks` cannot say "chosen again" when the same drawing is picked +twice, and because the five call sites (the importer and the four samples) +should not each have to remember. Where it goes is `sitsOnTable`, which is +therefore now a *remembered* preference rather than only the space's own +question: someone who has once said 「めのまえ」 is not asked again on the next +file. Nothing about it is a special case — the picker moves to wherever the +load put it, 「ださない」 takes it away, and a second file opened while one is +already out leaves the sheet exactly where it was dragged to, because `place` +sees the drawing is already there and returns. + +**SVG/PNG export was built here and then taken back out**, and the reason is +worth keeping so it is not re-added as an oversight: it worked, and cost one +view — `CanvasExportMenu` unchanged, rendering `lastRunCommands`, so moving the +drawing into an immersive space changed nothing about what came out. It came +out because a viewer cannot change a drawing, so the file it was handed is +already the artifact, and writing a second one from it belongs where drawings +are *made*. The window is a remote control, and its row had reached four +buttons. + diff --git a/App/Views/CodeWindow.swift b/App/Views/Viewer/CodeWindow.swift similarity index 100% rename from App/Views/CodeWindow.swift rename to App/Views/Viewer/CodeWindow.swift diff --git a/App/Views/ProgramWindow.swift b/App/Views/Viewer/ProgramWindow.swift similarity index 100% rename from App/Views/ProgramWindow.swift rename to App/Views/Viewer/ProgramWindow.swift diff --git a/App/Views/TableCanvas.swift b/App/Views/Viewer/TableCanvas.swift similarity index 96% rename from App/Views/TableCanvas.swift rename to App/Views/Viewer/TableCanvas.swift index 0165243..90b1f3d 100644 --- a/App/Views/TableCanvas.swift +++ b/App/Views/Viewer/TableCanvas.swift @@ -30,6 +30,12 @@ @MainActor final class ViewerModel { static let spaceID = "table" + + /// The remote itself. Named, unlike an app's usual first window, + /// because the other two are placed *relative to it* and + /// `WindowPlacement` needs a way to pick it out of + /// `WindowPlacementContext.windows`. + static let remoteWindowID = "remote" static let programWindowID = "program" static let codeWindowID = "code" @@ -228,6 +234,25 @@ /// desk would be if there were one. private static let floatingDrop: Float = 0.35 + /// How the sheet is framed, when something is dictating it. + /// + /// Nil in the app. Set from `-TBSheet` while App Store screenshots are + /// being shot, which is the one situation where the framing has to be + /// described rather than arrived at: the simulator has no hands, so + /// nothing there can pinch the sheet bigger or drag it further off. + /// + /// It overrides the two constants the *aimed* placement uses rather + /// than the position it computes, so a forced framing still lands in + /// front of the camera and turned to face it — which is the part of + /// the placement worth keeping. + struct Framing { + var side: Double + var reach: Float + var drop: Float + } + + var framing: Framing? + func placeOnTable(atHeight y: Float, device: simd_float4x4) { aim(from: device, height: y) placement = .onTable @@ -238,7 +263,7 @@ /// takes a fixed spot ahead of the origin instead. func floatInFront(device: simd_float4x4?) { if let device { - aim(from: device, height: device.columns.3.y - Self.floatingDrop) + aim(from: device, height: device.columns.3.y - (framing?.drop ?? Self.floatingDrop)) } else { home = [0, 1.0, -1.2] @@ -256,7 +281,7 @@ /// stand, the far edge is the top, which is the way a sheet of paper on /// a desk is oriented without anyone thinking about it. private func aim(from device: simd_float4x4, height y: Float) { - let target = Self.gazeTarget(from: device) + let target = Self.gazeTarget(from: device, distance: framing?.reach ?? Self.reach) home = [target.x, y, target.z] homeYaw = Self.yaw(from: device) } @@ -264,10 +289,11 @@ /// The spot the sheet aims for: `reach` metres ahead of the eyes, at /// eye height. Shared with surface picking, so "the table I am looking /// at" and "where on it the drawing goes" are the same point. - static func gazeTarget(from device: simd_float4x4) -> SIMD3 { + static func gazeTarget(from device: simd_float4x4, distance: Float = reach) -> SIMD3 + { let eye = SIMD3(device.columns.3.x, device.columns.3.y, device.columns.3.z) let ahead = forward(of: device) - return eye + ahead * reach + return eye + ahead * distance } static func yaw(from device: simd_float4x4) -> Float { diff --git a/App/Views/ViewerWindow.swift b/App/Views/Viewer/ViewerWindow.swift similarity index 70% rename from App/Views/ViewerWindow.swift rename to App/Views/Viewer/ViewerWindow.swift index 7f14186..9485955 100644 --- a/App/Views/ViewerWindow.swift +++ b/App/Views/Viewer/ViewerWindow.swift @@ -1,5 +1,6 @@ #if os(visionOS) + import OSLog import SwiftUI import TortoiseBlocksKit @@ -91,23 +92,47 @@ } .task { // Development only: the simulator cannot press any of these - // buttons (simctl sends no input), so `-TBPlace YES` loads a - // sample, opens the program window and puts the drawing down at - // launch. Without it a simulator run is a window of buttons + // buttons (simctl sends no input), so the launch arguments + // stand in for the hands. + // + // -TBPlace YES load a sample, open the program window, + // put the drawing down + // -TBSample square | star | spiral | tree + // -TBDraw <0…1> run the drawing that far and stop + // -TBSheet s,r,d the sheet's side, how far ahead of the + // eyes it lands, and how far below them + // + // Without the first one a simulator run is a window of buttons // nobody can reach, and the immersive space never opens at all. + // The other two exist because App Store screenshots are shot + // here rather than on a headset: a real room cannot be framed + // the same way twice and is somebody's home besides, while + // these four arguments describe a picture exactly. + // + // `-TBSheet` is the framing one, and it exists because + // nothing in the simulator can reach out and pinch the sheet + // bigger or drag it further off. It moves the two constants + // the placement is built from — reach and drop — rather than + // the position they produce, so a forced framing still lands + // in front of the camera and turned to face it. + // + // Worth knowing, since it was got wrong first: the simulator + // *does* report a usable head pose, so placement takes the + // aimed branch here exactly as a headset does, and an override + // written against the no-pose fallback silently does nothing. // - // **It does not let you see the drawing.** The simulator hosts - // no `ViewAttachmentComponent` view, so the sheet's own SwiftUI - // body never runs, `TortoisePlayer` never attaches to a canvas, - // and `currentTortoiseState` stays nil — which looks exactly - // like a broken tortoise and has cost an afternoon once - // already (the root CLAUDE.md has the longer note). What the - // flag is actually good for is everything that is not the - // picture: that the USDZ loads in the real visionOS runtime - // with the bounds its contract promises, that the per-frame - // subscription fires, that load → run → place survives, and - // that the program and code windows draw with content in them. - // The picture itself is the headset's to judge. + // **The simulator does show the drawing.** This comment said + // the opposite for a while, and the mistake is worth recording + // rather than quietly deleting: what was actually broken was + // that `-TBPlace` placed by calling `place(.inFront)` *after* + // the load, which flipped `sitsOnTable` and so changed the + // `.id()` on the immersive space's `RealityView` — tearing the + // scene down and rebuilding it, with the attachment failing to + // come back. The sheet stayed blank, `currentTortoiseState` + // stayed nil, and that looked exactly like a platform that + // hosts no `ViewAttachmentComponent`. Setting the preference + // before the load leaves the id alone, and the paper, the + // drawing and the tortoise all render. // // Read off the launch arguments rather than through // `UserDefaults`, which is where a `-flag value` pair normally @@ -118,22 +143,109 @@ // needs none, which is a property worth keeping for one line: // nothing else here touches a required-reason API, and the // launch command is unchanged either way. - guard ProcessInfo.processInfo.arguments.contains("-TBPlace"), - !model.hasProgram - else { - return - } + let arguments = ProcessInfo.processInfo.arguments + guard arguments.contains("-TBPlace"), !model.hasProgram else { return } + // Loading is what places it now, so the destination is // chosen by setting the preference first. In front rather // than on a table because the simulator finds no planes at // all, and a table search there only spends its fifteen // seconds before falling back to exactly this. model.sitsOnTable = false - model.load(SampleBlocks.spiral(), title: String(localized: "Spiral")) + if let framing = Self.framing(from: Self.value(of: "-TBSheet", in: arguments)) { + model.side = framing.side + model.framing = framing + } + let (blocks, title) = Self.sample(named: Self.value(of: "-TBSample", in: arguments)) + model.load(blocks, title: title) + // Both, because that is what the platform's argument looks + // like when it is working: the drawing on the table, the + // blocks it is made from, and the code they become, all at + // once. They open beside the remote rather than over it — see + // `defaultWindowPlacement` in `TortoiseBlocksApp`. openWindow(id: ViewerModel.programWindowID) + openWindow(id: ViewerModel.codeWindowID) + + await settle( + drawingTo: Self.value(of: "-TBDraw", in: arguments).flatMap(Double.init)) + } + } + + /// The value of a `-flag value` pair on the command line. + private static func value(of flag: String, in arguments: [String]) -> String? { + guard let flagIndex = arguments.firstIndex(of: flag), + arguments.index(after: flagIndex) < arguments.endIndex + else { + return nil } + return arguments[arguments.index(after: flagIndex)] } + /// `side,reach,drop` in metres — how big the sheet is, how far ahead + /// of the eyes it lands, and how far below them. + /// + /// All three or nothing: a partial list would leave some of the + /// framing to a default and the rest to the argument, which is the one + /// thing a reproducible capture cannot have. + private static func framing(from value: String?) -> ViewerModel.Framing? { + let numbers = (value ?? "").split(separator: ",").compactMap { Double($0) } + guard numbers.count == 3 else { return nil } + + return ViewerModel.Framing( + side: numbers[0], reach: Float(numbers[1]), drop: Float(numbers[2])) + } + + /// The sample `-TBSample` names, defaulting to the spiral. + private static func sample(named name: String?) -> ([Block], String) { + switch name?.lowercased() { + case "square": (SampleBlocks.filledSquare(), String(localized: "Filled Square")) + case "star": (SampleBlocks.star(), String(localized: "Star")) + case "tree": (SampleBlocks.fractalTree(), String(localized: "Tree")) + default: (SampleBlocks.spiral(), String(localized: "Spiral")) + } + } + + /// Waits until the drawing is really on the paper, draws `fraction` of + /// it, and says so in the log. + /// + /// Two things need the same wait. The seek cannot go out until the + /// sheet's canvas has attached itself to the player — + /// `TortoisePlayer.seek` is a no-op before that, and the attachment + /// cannot happen until the immersive space has opened and its view has + /// rendered. And `Tools/visionos-shots.rb` needs to know when to press + /// the shutter, which is the same moment. `currentTortoiseState` + /// turning non-nil *is* it, and there is nothing to await on, so this + /// polls. + /// + /// The failure is worth a line of its own rather than a timeout the + /// script has to infer: a run occasionally comes up with no sheet at + /// all, and from the outside that is indistinguishable from one that is + /// merely slow. Told which it is, the script can relaunch instead of + /// filing a picture of an empty room. + private func settle(drawingTo fraction: Double?) async { + let clock = ContinuousClock() + let deadline = clock.now + .seconds(20) + while model.runner.player.currentTortoiseState == nil, clock.now < deadline { + try? await Task.sleep(for: .milliseconds(100)) + } + guard model.runner.player.currentTortoiseState != nil else { + Self.shoot.error("TBNotReady") + return + } + + let commands = model.runner.lastRunCommands.count + if let fraction, commands > 0 { + let clamped = min(max(fraction, 0), 1) + model.runner.seek(to: Int((Double(commands - 1) * clamped).rounded())) + } + Self.shoot.notice("TBReady") + } + + /// Only ever written to under `-TBPlace`, and read only by the + /// screenshot script. + private static let shoot = Logger( + subsystem: "space.hiraku.tortoiseblocks", category: "shoot") + /// Moves the drawing to `destination`, opening or closing the /// immersive space as that requires. /// diff --git a/CLAUDE.md b/CLAUDE.md index 78c39d5..4081f20 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,8 +10,8 @@ cd TortoiseBlocksKit && swift test # Kit unit tests (fast, UI-independent # Format / lint (config: /.swift-format, upstream-mirrored; Xcode 26's `swift format`). # Note: `~/.swiftly/bin/swift-format` is a legacy binary that ignores the config — # always use the `swift format` subcommand. -swift format --in-place --recursive App ThumbnailExtension TortoiseBlocksKit/Sources TortoiseBlocksKit/Tests -swift format lint --strict --recursive App ThumbnailExtension TortoiseBlocksKit/Sources TortoiseBlocksKit/Tests # CI gate +swift format --in-place --recursive App ThumbnailExtension TortoiseBlocksUITests TortoiseBlocksKit/Sources TortoiseBlocksKit/Tests +swift format lint --strict --recursive App ThumbnailExtension TortoiseBlocksUITests TortoiseBlocksKit/Sources TortoiseBlocksKit/Tests # CI gate # App builds (both must stay green): xcodebuild -project TortoiseBlocks.xcodeproj -scheme TortoiseBlocks \ @@ -22,15 +22,21 @@ xcodebuild -project TortoiseBlocks.xcodeproj -scheme TortoiseBlocks \ # Manual verification loop (macOS): pkill -x TortoiseBlocks; open ~/Library/Developer/Xcode/DerivedData/TortoiseBlocks-*/Build/Products/Debug/TortoiseBlocks.app -# visionOS. `-TBPlace` is the only way in: simctl sends no input, so without it -# the run is a window of buttons nobody can press. It does NOT show the drawing -# (see the tortoise note below) — it is for the load path, the USDZ, and the -# program/code windows. +# visionOS. Launch arguments are the only way in: simctl sends no input, so +# without them the run is a window of buttons nobody can press. `-TBPlace` +# loads a sample and puts it down, `-TBSample` picks which (square|star| +# spiral|tree), `-TBDraw` runs the drawing that far (0…1) and stops, and +# `-TBSheet side,reach,drop` frames it (metres). The sheet is aimed at the +# camera, so no recentring — but a run occasionally comes up without it, so +# look at the capture. xcrun simctl install ~/Library/Developer/Xcode/DerivedData/TortoiseBlocks-*/Build/Products/Debug-xrsimulator/TortoiseBlocks.app -xcrun simctl launch space.hiraku.tortoiseblocks -TBPlace YES +xcrun simctl launch space.hiraku.tortoiseblocks \ + -TBPlace YES -TBSample star -TBDraw 1 -TBSheet 0.5,0.95,0.42 +xcrun simctl io screenshot shot.png # 3840x2160, with an alpha channel # The App Store listing (appstore/). The check needs no key and no bundle; # the other two need ASC_ISSUER_ID / ASC_KEY_ID / ASC_PRIVATE_KEY_PATH. +ruby Tools/screenshots.rb # after ANY reshoot: strip alpha, optimise, rebuild site/shots ruby fastlane/metadata_check.rb # what CI runs on every pull request bundle exec fastlane ios metadata_diff # live listing vs what is written bundle exec fastlane ios metadata_push # upload (mac for the other listing) @@ -426,136 +432,13 @@ Check a change here in the *built* product rather than in Xcode — `assetutil fails the same silent way a missing one does: the system's placeholder, which looks like a plain app that hasn't been styled yet. -**The 3D tortoise is generated, not modelled** (#53). -`App/Resources/Tortoise.usdz` — the sprite the immersive space will draw with — -comes out of `Tools/tortoise-model/build_tortoise.py`, a Blender script whose -constants *are* the three-view drawing's measurements. It is checked in -alongside the script so no build step needs Blender. Four things about it are a -contract app code will assume, and the reasoning for each is in -`Tools/tortoise-model/README.md`: `upAxis = "Y"` with **forward at `-Z`** (the -model is authored Z-up and the exporter puts `rotateXYZ = (-90, 0, 0)` on the -root — wrong settings here are invisible until the tortoise drives sideways); -**total length exactly 1.0** with `metersPerUnit = 1`, normalised rather than -real-world because the canvas is a 0.2–2m gesture and the size is always -computed anyway; the **origin is the ground point under the shell's centre**, -the point it turns about, *not* the brush tip, so the drawn line trails behind -the animal; and the whole thing rides in `App/` as a synchronized-folder -resource, landing flat at `Contents/Resources/Tortoise.usdz` (verified in the -built bundle, the only way that works — see the nested-CLAUDE.md note above). -Blender rendering it proves nothing about RealityKit; `qlcheck.swift` in the -same directory runs it through Apple's own USD stack instead. -A fifth thing is a contract with the *room* rather than with app code: **every -material emits a third of its own colour**, because a `.mixed` immersive space -lights the model with the real room and a lamp-lit evening one drained the -pastels to mud (measured: luminance 39 of 255, gold reading brown — 111 with -emission, and the facets still step). Do not "fix" it as a PBR error, and do -not lighten the colours instead: those are sampled from the drawing, which is -the specification. The reasoning and the shell's second texture are in the -tool README. - -**The tortoise on the table is drawn by us, and that took a library release** -(#53 Phase 3, TortoiseGraphics2 2.1.0). The sheet is still the app's own -`TortoiseCanvas` in a `ViewAttachmentComponent`; what changed is that it now -draws everything *except* the tortoise (`.tortoiseSprite(.hidden)`), and the -USDZ stands on the paper as a child of the sheet entity — so the pinch, twist -and drag it inherits for free, and its own transform only ever says where on -the page it is. Three upstream additions were needed and none of them had an -honest app-side substitute. `.hidden` is a property of the *view*, unlike -`hideTortoise()`, which records a command and would have followed the drawing -into the SVG, the PNG, the thumbnail and the saved file. -`TortoisePlayer.currentTortoiseState` is the pose **interpolated between -commands**: `currentCommandIndex` — what every other surface in the app watches -— changes about ten times a second, and a tortoise moved on that schedule -teleports from command to command while the line it is drawing grows smoothly -underneath it, which is the one thing this feature exists to show. And -`ViewportMode.transform` is public so the placement asks for `autoFit`'s -mapping rather than reimplementing it; a reimplementation agrees on the day it -is written and drifts silently after. It is read once per *display frame*, from -a `SceneEvents.Update` subscription — not from `body`, which would re-evaluate -the view at the refresh rate — and the subscription has to be retained -(`FrameTicker`), because one that nothing holds is cancelled at the end of -`make` and looks exactly like a handler that is never called. -Two numbers are judged on device and are the first things to change if it looks -wrong: the tortoise is `1/12` of the sheet's side (deliberately larger than the -2-D sprite's ~1/30 — on a screen it is a cursor, on a table it is the animal), -and the paper keeps a 64pt margin, since a hidden sprite earns no `autoFit` -inset and the drawing would otherwise run to the paper's edge with the tortoise -hanging off it. The lift onto the paper is *measured* from the loaded model, -not assumed: the feet reach ~6‰ of the body length below the origin, which is -the ground point under the shell's centre. -**The visionOS simulator cannot check any of this.** It does not host -`ViewAttachmentComponent` views at all — the sheet's own `.task` never runs, so -`TortoisePlayer` never attaches to a canvas and `currentTortoiseState` stays -nil, which reads exactly like a broken tortoise. What the simulator *is* good -for is the two things that would otherwise be guesses: that the USDZ loads in -the real visionOS runtime with the bounds the contract promises, and that the -per-frame subscription fires. Everything else is the headset. - -**The viewer has three surfaces, and the third is the code** (#53 Phase 3). -Table, program, code — a `WindowGroup` each, all open at once. That is the -whole argument for the platform restated one step further: iPad and Mac make -the canvas and the code two states of *one toggle* because a window holds one -of them, and a headset never has to choose. The code window is `CodePane` -unchanged, which #11 had already made work here by taking it off -`.background.secondary` (translucent glass on this platform, with the syntax -colours left standing on nothing). The source is generated in -`ViewerModel.load` rather than in the window's `body`: the iPad's pane is only -in the hierarchy while its toggle says so, but a window redraws on its own -schedule and nothing here can edit the program behind it. -**The remote's controls are grouped by what they do, not by what they are.** -The row used to read 「つくえに おく」「ブロックを みる」「コードを みる」, whose -only shared property was being buttons — one placed the drawing, two opened -windows — while placement's own mode switch and reset sat in a *different* row -underneath with those two wedged between. Placement is now one group with its -own question over it, the other surfaces are another below a divider, and three -things fell out of doing it. The two verbs went: 「つくえに おく」 (put the -drawing down) and 「つくえに のせる」 (look for a table at all) were nearly the -same words for different things, invisible while they sat apart and unbearable -once grouped — so `ViewerModel.placing` names the **three** states the window -actually has (away / table / in front) and one picker asks them. It stays -read-only and the window drives it through an async action, because `isPlaced` -is only true once the space has really opened and a refused world-sensing prompt -must leave the picker showing where the drawing *is*. A visionOS **ornament** was the other candidate for those -two — the platform's own place for "belongs to this window but is not its -content" — and was turned down: it is always visible, so it hangs under the -window even in the small "えが ありません" state and adds its height to every -glance, and a divider already says the difference for nothing. The window -buttons became toggles, since `openWindow` on an open window only brings it -forward — a switch with one position — so the windows report themselves through -`isProgramWindowOpen` / `isCodeWindowOpen`, there being nothing in SwiftUI to -read that from. And floating stopped being an error: it can now be *chosen*, so -`PlacementStatus` says "no table found" only when a table was actually asked -for. - -**Opening a drawing puts it down**, and that is the placement group's last -open question answered. Choosing a file used to change nothing but the window: -the room stayed empty until the picker was touched, so the app read as one -that had not opened the file — the state with the least to look at was the one -reached by doing the thing the app is for. An alert asking "shall I put it on -the table?" was the obvious fix and is the wrong one twice over: the answer is -always yes, and the first placement already raises the world-sensing prompt, so -it would be two modals in a row before anything appeared. So a load *is* a -placement, through `ViewerModel.loadGeneration` — a counter rather than a flag, -because `blocks` cannot say "chosen again" when the same drawing is picked -twice, and because the five call sites (the importer and the four samples) -should not each have to remember. Where it goes is `sitsOnTable`, which is -therefore now a *remembered* preference rather than only the space's own -question: someone who has once said 「めのまえ」 is not asked again on the next -file. Nothing about it is a special case — the picker moves to wherever the -load put it, 「ださない」 takes it away, and a second file opened while one is -already out leaves the sheet exactly where it was dragged to, because `place` -sees the drawing is already there and returns. - -**SVG/PNG export was built here and then taken back out**, and the reason is -worth keeping so it is not re-added as an oversight: it worked, and cost one -view — `CanvasExportMenu` unchanged, rendering `lastRunCommands`, so moving the -drawing into an immersive space changed nothing about what came out. It came -out because a viewer cannot change a drawing, so the file it was handed is -already the artifact, and writing a second one from it belongs where drawings -are *made*. The window is a remote control, and its row had reached four -buttons. +**The visionOS viewer is in `App/Views/Viewer/CLAUDE.md`.** The drawing on the +table, the 3D tortoise and its contract, the three windows, the remote's +controls, and what the simulator can and cannot tell you — all of it scoped to +the four files it describes. **Releasing, the store listing and the website are in the `release` skill.** Tags, Xcode Cloud, TestFlight, `appstore/`, fastlane, and `site/`. +**Making the pictures is the `screenshots` skill** — the capture rigs for iPad, Mac and Vision Pro, the pass every reshoot ends with (`ruby Tools/screenshots.rb`), and the traps that hand back a perfectly well-made capture of the wrong thing. **Localization**: `en` is the source language; Japanese (kid-friendly hiragana) lives in `App/Localizable.xcstrings`. Palette titles are diff --git a/Tools/ipad-shots.rb b/Tools/ipad-shots.rb new file mode 100755 index 0000000..1e5b29b --- /dev/null +++ b/Tools/ipad-shots.rb @@ -0,0 +1,165 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Shoots the iPad App Store captures, all of them, from the simulator. +# +# ruby Tools/ipad-shots.rb # every shot, both locales +# ruby Tools/ipad-shots.rb star # only the shots whose name matches +# +# **Why a UI test and not launch arguments**, which is how the visionOS +# captures are made: rotation. `simctl` cannot turn an iPad, and driving the +# Simulator's own menu means granting keystroke permission to whatever runs +# this. `XCUIDevice` rotates in a line — and the same mechanism then presses +# play and switches panes, so the app keeps no screenshot-only code at all. +# `TortoiseBlocksUITests/ScreenshotTests.swift` is the hands; this is the +# shot list and the plumbing. +# +# Three things here are not tidiness, and each cost an hour to find. +# +# The app is **uninstalled before every run**. Opening a document that is not +# in the app's own folder imports a copy, and the name is deduplicated against +# a history that outlives deleting the files — so the title bar creeps to +# `star-9` and the capture is unusable. Uninstalling resets it. +# +# The documents are seeded into the **device's** tmp, not the app's container. +# Preparing a test run reinstalls the app, and a reinstall gives it a new data +# container, so anything seeded there beforehand is gone by the time the test +# opens it. +# +# The `TEST_RUNNER_` variables are set on **this process's environment**, not +# passed as `KEY=value` arguments to xcodebuild. Both are accepted; only one +# arrives. + +require "fileutils" +require "tmpdir" +require "json" +require "pathname" + +ROOT = Pathname.new(__dir__).parent +DESTINATION = ROOT / "appstore" / "screenshots" / "ios" +SOURCES = ROOT / "appstore" / "screenshot-sources" +BUNDLE_ID = "space.hiraku.tortoiseblocks" +DEVICE_NAME = "iPad Pro 13-inch (M5)" + +# App Store locale directory → the language the app is launched in. +LOCALES = { "en-US" => "en", "ja" => "ja" }.freeze + +# The shot list: which drawing, and which pane to end up on. The same four the +# listing has always had. +SHOTS = [ + { name: "1_star_canvas", sample: "star", pane: "canvas" }, + { name: "2_spiral_canvas", sample: "spiral", pane: "canvas" }, + { name: "3_spiral_code", sample: "spiral", pane: "code" }, + { name: "4_tree_canvas", sample: "tree", pane: "canvas" } +].freeze + +def simctl(*arguments) + IO.popen(["xcrun", "simctl", *arguments], err: %i[child out], &:read) +end + +def device + json = JSON.parse(simctl("list", "devices", "available", "-j")) + candidates = json["devices"].flat_map do |runtime, list| + runtime.include?("iOS") ? list.select { |d| d["name"] == DEVICE_NAME } : [] + end + abort("No #{DEVICE_NAME} simulator.") if candidates.empty? + + booted = candidates.find { |d| d["state"] == "Booted" } || candidates.first + udid = booted["udid"] + if booted["state"] != "Booted" + simctl("boot", udid) + simctl("bootstatus", udid) + end + udid +end + +wanted = ARGV.reject { |argument| argument.start_with?("-") } +shots = wanted.empty? ? SHOTS : SHOTS.select { |shot| wanted.any? { |w| shot[:name].include?(w) } } +abort("Nothing matches #{wanted.join(', ')}") if shots.empty? + +udid = device +puts "device #{udid}" + +# 9:41, the way every Apple screenshot has been since the first iPhone was +# shown. It is also the only way this is reproducible: without it the captures +# carry whatever the clock said, and a reshoot never matches the set it joins. +simctl( + "status_bar", udid, "override", + "--time", "9:41", "--batteryState", "charged", "--batteryLevel", "100", + "--wifiMode", "active", "--wifiBars", "3", "--cellularMode", "notSupported" +) + +seed = Pathname.new(Dir.home) / "Library/Developer/CoreSimulator/Devices" / udid / "data/tmp/tbshots" +FileUtils.mkdir_p(seed) +FileUtils.cp(Pathname.glob(SOURCES / "*.tortoise").map(&:to_s), seed) + +# Shots grouped so that no drawing is opened twice in one run. +# +# Opening a document that is not in the app's own folder imports a copy, and +# the copy's name is deduplicated against a history that outlives deleting the +# files — so the second capture of the spiral came back titled `spiral-1`. +# Uninstalling resets that history, so the fix is to uninstall between the +# groups rather than to open the file once and photograph it twice: two +# captures of one document are two *panes*, and going back to the canvas after +# the code pane is more state to keep straight than a second launch is worth. +groups = shots.each_with_object([]) do |shot, list| + slot = list.find { |group| group.none? { |other| other[:sample] == shot[:sample] } } + slot ? slot << shot : list << [shot] +end + +LOCALES.to_a.product(groups).each do |(locale, language), group| + puts "#{locale}: #{group.map { |s| s[:name] }.join(', ')}" + simctl("uninstall", udid, BUNDLE_ID) + + workspace = Pathname.new(Dir.mktmpdir) + result = workspace / "shots.xcresult" + + environment = { + "TEST_RUNNER_TB_DOCUMENTS" => seed.to_s, + "TEST_RUNNER_TB_SHOTS" => group.map { |s| "#{s[:name]}:#{s[:sample]}:#{s[:pane]}" }.join(","), + "TEST_RUNNER_TB_LOCALE" => locale, + "TEST_RUNNER_TB_LANGUAGE" => language + } + command = [ + "xcodebuild", "test", + "-project", (ROOT / "TortoiseBlocks.xcodeproj").to_s, + "-scheme", "TortoiseBlocks", + "-destination", "id=#{udid}", + "-only-testing:TortoiseBlocksUITests/ScreenshotTests", + "-parallel-testing-enabled", "NO", + "-resultBundlePath", result.to_s, + "-quiet" + ] + abort("#{locale}: the test run failed") unless system(environment, *command) + + exported = Pathname.new(Dir.mktmpdir) + unless system("xcrun", "xcresulttool", "export", "attachments", + "--path", result.to_s, "--output-path", exported.to_s, + out: File::NULL, err: File::NULL) + abort("#{locale}: could not export the captures") + end + + manifest = JSON.parse((exported / "manifest.json").read) + filed = manifest.flat_map { |test| test["attachments"] }.filter_map do |attachment| + # "ja|1_star_canvas_0_.png" — the part before the pipe is where it + # goes, the part after is what it is called. + where, rest = attachment["suggestedHumanReadableName"].split("|", 2) + next if rest.nil? + + name = rest.sub(/_\d+_[0-9A-F-]+\.png\z/, "") + target = DESTINATION / where / "#{name}.png" + FileUtils.mkdir_p(target.dirname) + FileUtils.cp(exported / attachment["exportedFileName"], target) + puts " → #{target.relative_path_from(ROOT)}" + target + end + abort("#{locale}: no captures came back") if filed.empty? + + FileUtils.rm_rf(exported) + FileUtils.rm_rf(workspace) +end + +simctl("status_bar", udid, "clear") + +puts "\nflattening and optimising" +system("ruby", (ROOT / "Tools" / "screenshots.rb").to_s) || abort("screenshots.rb failed") diff --git a/Tools/macos-shots.rb b/Tools/macos-shots.rb new file mode 100755 index 0000000..87fb98e --- /dev/null +++ b/Tools/macos-shots.rb @@ -0,0 +1,177 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Shoots the macOS App Store captures: the app's window, composited onto a +# prepared plate. +# +# ruby Tools/macos-shots.rb # every shot, both locales +# ruby Tools/macos-shots.rb star # only the shots whose name matches +# +# **The capture is the window alone**, taken by `XCUIElement.screenshot()` in +# `TortoiseBlocksUITests/ScreenshotTests.swift`. Everything around it — the +# desktop and the menu bar — comes from `appstore/screenshot-sources/ +# macos-plate-{en,ja}.png`, made once per language. That is what keeps the +# capture independent of the machine: whatever wallpaper, menu extras or clock +# the Mac happens to have never reach the picture. +# +# The plates carry **no shadow**. It is generated here instead, so the window +# can change size or move without the plates being remade — which matters, +# because the previous set had one capture whose window sat 14px off the other +# seven and a baked shadow would have meant redrawing the artwork to fix it. +# +# macOS UI testing needs Xcode to hold the **Accessibility** permission +# (System Settings ▸ Privacy & Security ▸ Accessibility). Without it every run +# fails with "Timed out while enabling automation mode", which says nothing +# about permissions at all. + +require "fileutils" +require "json" +require "pathname" +require "tmpdir" + +ROOT = Pathname.new(__dir__).parent +DESTINATION = ROOT / "appstore" / "screenshots" / "macos" +SOURCES = ROOT / "appstore" / "screenshot-sources" +BUNDLE_ID = "space.hiraku.tortoiseblocks" + +LOCALES = { "en-US" => "en", "ja" => "ja" }.freeze + +SHOTS = [ + { name: "1_star_canvas", sample: "star", pane: "canvas" }, + { name: "2_spiral_canvas", sample: "spiral", pane: "canvas" }, + { name: "3_spiral_code", sample: "spiral", pane: "code" }, + { name: "4_tree_canvas", sample: "tree", pane: "canvas" } +].freeze + +# The menu bar the plates were drawn with, in pixels. Everything else about the +# placement is computed, so a differently sized window still lands centred. +MENU_BAR = 48 + +# opacity × blur, then the drop. Judged against the previous hand-made set. +SHADOW = "55x30+0+22" + +def magick(*arguments) + return if system("magick", *arguments, err: File::NULL) + + abort("magick failed: #{arguments.join(' ')}") +end + +def size(path) + IO.popen(["magick", "identify", "-format", "%w %h", path.to_s], &:read).split.map(&:to_i) +end + +# The window with its rounded corners knocked out. +# +# `XCUIElement.screenshot()` hands back the window's *bounding box*, fully +# opaque, with the corners filled near-black — composite that and the window +# gets four black wedges. The corners are flood-filled rather than masked with +# a drawn radius: the shape is macOS's own continuous curve, not a circle, and +# the wedges are the only near-black regions touching the corners of the image. +def round_corners(source, target) + width, height = size(source) + magick( + source.to_s, "-alpha", "set", "-fuzz", "12%", + "-fill", "none", "-draw", "color 0,0 floodfill", + "-fill", "none", "-draw", "color #{width - 1},0 floodfill", + "-fill", "none", "-draw", "color 0,#{height - 1} floodfill", + "-fill", "none", "-draw", "color #{width - 1},#{height - 1} floodfill", + target.to_s + ) +end + +def compose(window, plate, target) + workspace = Pathname.new(Dir.mktmpdir) + rounded = workspace / "window.png" + shadow = workspace / "shadow.png" + round_corners(window, rounded) + magick(rounded.to_s, "-background", "black", "-shadow", SHADOW, shadow.to_s) + + plate_width, plate_height = size(plate) + window_width, window_height = size(rounded) + x = (plate_width - window_width) / 2 + y = MENU_BAR + (plate_height - MENU_BAR - window_height) / 2 + + # `-shadow` grows the canvas and records how far by in the page offset, so + # the shadow lands under the window rather than beside it. + offset = IO.popen( + ["magick", "identify", "-format", "%[fx:page.x] %[fx:page.y]", shadow.to_s], &:read + ).split.map(&:to_i) + + magick( + plate.to_s, + shadow.to_s, "-gravity", "NorthWest", "-geometry", "+#{x + offset[0]}+#{y + offset[1]}", + "-composite", + rounded.to_s, "-gravity", "NorthWest", "-geometry", "+#{x}+#{y}", "-composite", + target.to_s + ) + FileUtils.rm_rf(workspace) +end + +wanted = ARGV.reject { |argument| argument.start_with?("-") } +shots = wanted.empty? ? SHOTS : SHOTS.select { |shot| wanted.any? { |w| shot[:name].include?(w) } } +abort("Nothing matches #{wanted.join(', ')}") if shots.empty? + +seed = Pathname.new(Dir.mktmpdir) +FileUtils.cp(Pathname.glob(SOURCES / "*.tortoise").map(&:to_s), seed) + +LOCALES.each do |locale, language| + plate = SOURCES / "macos-plate-#{language}.png" + abort("Missing #{plate.relative_path_from(ROOT)}") unless plate.exist? + + puts "#{locale}: #{shots.map { |s| s[:name] }.join(', ')}" + + # Every capture has to be the same size, and macOS restores a window's saved + # frame in preference to the app's `defaultSize`. Throwing the saved state + # away is what makes 1280×800pt — and so 2560×1600px — reproducible. + FileUtils.rm_rf(Pathname.new(Dir.home) / "Library/Saved Application State/#{BUNDLE_ID}.savedState") + system("defaults", "delete", BUNDLE_ID, out: File::NULL, err: File::NULL) + + workspace = Pathname.new(Dir.mktmpdir) + result = workspace / "shots.xcresult" + environment = { + "TEST_RUNNER_TB_DOCUMENTS" => seed.to_s, + "TEST_RUNNER_TB_SHOTS" => shots.map { |s| "#{s[:name]}:#{s[:sample]}:#{s[:pane]}" }.join(","), + "TEST_RUNNER_TB_LOCALE" => locale, + "TEST_RUNNER_TB_LANGUAGE" => language + } + command = [ + "xcodebuild", "test", + "-project", (ROOT / "TortoiseBlocks.xcodeproj").to_s, + "-scheme", "TortoiseBlocks", + "-destination", "platform=macOS", + "-only-testing:TortoiseBlocksUITests/ScreenshotTests", + "-parallel-testing-enabled", "NO", + "-resultBundlePath", result.to_s, + "-quiet" + ] + abort("#{locale}: the test run failed") unless system(environment, *command) + + exported = Pathname.new(Dir.mktmpdir) + unless system("xcrun", "xcresulttool", "export", "attachments", + "--path", result.to_s, "--output-path", exported.to_s, + out: File::NULL, err: File::NULL) + abort("#{locale}: could not export the captures") + end + + manifest = JSON.parse((exported / "manifest.json").read) + filed = manifest.flat_map { |test| test["attachments"] }.filter_map do |attachment| + where, rest = attachment["suggestedHumanReadableName"].split("|", 2) + next if rest.nil? + + name = rest.sub(/_\d+_[0-9A-F-]+\.png\z/, "") + target = DESTINATION / where / "#{name}.png" + FileUtils.mkdir_p(target.dirname) + compose(exported / attachment["exportedFileName"], plate, target) + puts " → #{target.relative_path_from(ROOT)}" + target + end + abort("#{locale}: no captures came back") if filed.empty? + + FileUtils.rm_rf(exported) + FileUtils.rm_rf(workspace) +end + +FileUtils.rm_rf(seed) + +puts "\nflattening and optimising" +system("ruby", (ROOT / "Tools" / "screenshots.rb").to_s) || abort("screenshots.rb failed") diff --git a/Tools/screenshots.rb b/Tools/screenshots.rb new file mode 100755 index 0000000..3df6890 --- /dev/null +++ b/Tools/screenshots.rb @@ -0,0 +1,137 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Makes appstore/screenshots sendable, and rebuilds the copies the website uses. +# +# ruby Tools/screenshots.rb # the usual run, after a reshoot +# ruby Tools/screenshots.rb --all # re-optimise every capture, not just new ones +# +# **Every capture arrives with an alpha channel**, and App Store Connect +# refuses one. That is not carelessness at the export step: the Mac shots are +# composed in Figma and the iPad ones come from the simulator's screenshot +# button, and neither can be told to write RGB. So the channel is removed here +# rather than argued with there — and since it has always been fully opaque, +# removing it is lossless. That is checked, not assumed: a capture with real +# transparency stops the run, because compositing it onto a background this +# script picked would silently change the picture. +# +# It then does the step that is easy to forget, having been forgotten twice: +# site/shots/*.png are downscaled copies of seven of these captures, so a +# reshoot that stops at appstore/ leaves the website showing the previous +# build's UI. They are regenerated every run. The derivation is deterministic — +# rerunning it against unchanged captures reproduces the committed files byte +# for byte — so a no-op run leaves the working tree clean. +# +# Needs `magick` (ImageMagick 7) and `oxipng`, both from Homebrew. It ends by +# running the same check CI runs, so a green finish means the tree is sendable. +# +# One thing to expect from `--all`: oxipng at `-o max` is not bit-for-bit +# reproducible across runs, so re-optimising an already-optimal capture can +# rewrite it by a few dozen bytes with the pixels untouched. That is noise in a +# diff, not a change, and it is the reason the default run only touches what it +# just flattened. + +require "fileutils" +require "pathname" +require "tmpdir" + +require_relative "../fastlane/metadata_check" + +ROOT = Pathname.new(__dir__).parent +SCREENSHOTS = ROOT / "appstore" / "screenshots" +SITE_SHOTS = ROOT / "site" / "shots" + +# Which captures the website uses, and how big. A curated subset rather than a +# rule — the code pane is on the Mac half of the page and not the iPad half — +# so it is a table, and a new site image has to be added to it by hand. +DERIVED = { + "ios" => { + prefix: "ipad", + size: "1600x1200", + locales: { "en-US" => "en", "ja" => "ja" }, + shots: { "1_star_canvas" => "1", "2_spiral_canvas" => "2", "4_tree_canvas" => "4" } + }, + "macos" => { + prefix: "mac", + size: "1400x875", + locales: { "en-US" => "en", "ja" => "ja" }, + # The star went to the Vision Pro row when the platform section got a + # picture of its own; the code pane is the Mac's remaining job on the page. + shots: { "3_spiral_code" => "3" } + }, + # One is enough: the drawing on the table is the whole of what this platform + # adds, and every visitor downloads both languages' images. + "visionos" => { + prefix: "vision", + size: "1400x788", + locales: { "en-US" => "en", "ja" => "ja" }, + shots: { "1_star_table" => "1" } + } +}.freeze + +def run(*command) + return if system(*command) + + abort("failed: #{command.join(' ')}") +end + +def captures + Pathname.glob(SCREENSHOTS / "*" / "*" / "*.png").sort +end + +def relative(path) + path.relative_path_from(ROOT) +end + +# Drops the alpha channel, refusing anything that is not already opaque. +# +# `-alpha off` discards the channel and leaves RGB untouched, which is what +# makes this lossless. Compositing (`-background white -flatten`) would not be: +# it would need a colour, and the right colour is whatever the capture was +# taken against. +def flatten(path) + # No shell in the way, so no quoting to get wrong. + opaque = IO.popen(["magick", "identify", "-format", "%[opaque]", path.to_s], &:read).strip + unless opaque.casecmp("true").zero? + abort("#{relative(path)} has real transparency, not just an unused channel — " \ + "flattening it would need a background colour, which is a decision for a person") + end + + puts "flattening #{relative(path)}" + Dir.mktmpdir do |tmp| + out = File.join(tmp, path.basename.to_s) + run("magick", path.to_s, "-alpha", "off", out) + FileUtils.cp(out, path) + end +end + +all = ARGV.include?("--all") + +flattened = captures.select { |path| MetadataCheck.alpha?(path) } +flattened.each { |path| flatten(path) } + +# Only what changed, unless asked otherwise: `-o max` searches hard enough that +# re-running it over a set that is already optimal costs minutes and saves +# nothing. +optimise = all ? captures : flattened +unless optimise.empty? + puts "optimising #{optimise.count} capture(s) — this takes a while at -o max" + run("oxipng", "-q", "-o", "max", "--strip", "safe", *optimise.map(&:to_s)) +end + +DERIVED.each do |platform, spec| + spec[:locales].each do |locale, short| + spec[:shots].each do |capture, index| + source = SCREENSHOTS / platform / locale / "#{capture}.png" + next warn("missing #{relative(source)}, skipping its site copy") unless source.exist? + + target = SITE_SHOTS / "#{spec[:prefix]}-#{short}-#{index}.png" + run("magick", source.to_s, "-resize", spec[:size], "-dither", "None", + "-colors", "256", "-strip", target.to_s) + end + end +end +run("oxipng", "-q", "-o", "max", "--strip", "safe", *Pathname.glob(SITE_SHOTS / "*.png").map(&:to_s)) +puts "site/shots regenerated" + +exit(MetadataCheck.report ? 0 : 1) diff --git a/Tools/visionos-shots.rb b/Tools/visionos-shots.rb new file mode 100755 index 0000000..22bd9e9 --- /dev/null +++ b/Tools/visionos-shots.rb @@ -0,0 +1,159 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Shoots the visionOS App Store screenshots, all of them, from the simulator. +# +# ruby Tools/visionos-shots.rb # every shot, both locales +# ruby Tools/visionos-shots.rb star spiral # only the shots whose name matches +# +# Build the visionOS scheme first; this installs whatever is in DerivedData. +# +# **Why the simulator and not a headset.** A room cannot be framed the same way +# twice, and it is somebody's home. Here the picture is described entirely by +# the launch line — which drawing, how far through it, how the sheet sits, what +# language the app speaks — so a reshoot is this file plus a command, and +# tuning the framing is editing SHOTS and running it again. +# +# The one thing that is not deterministic is whether the sheet turns up at all: +# some launches open the immersive space with no attachment, and from the +# outside that looks exactly like a slow one. So the app says which it is — +# `TBReady` once the canvas has attached and the drawing is on the paper, +# `TBNotReady` if it gives up — and a shot that does not report ready is +# relaunched rather than photographed. Waiting on `EntityLoad`, which is only +# the USDZ arriving, files pictures of empty rooms. + +require "fileutils" +require "json" +require "pathname" + +ROOT = Pathname.new(__dir__).parent +DESTINATION = ROOT / "appstore" / "screenshots" / "visionos" +BUNDLE_ID = "space.hiraku.tortoiseblocks" + +# App Store locale directory → the language the app is launched in. +LOCALES = { "en-US" => "en", "ja" => "ja" }.freeze + +# The shot list. `sheet` is `side,reach,drop` in metres — how big the paper is, +# how far ahead of the eyes it lands, how far below them — and `draw` is how +# far through the program to stop, so a value under 1 catches the tortoise +# mid-line with the drawing still growing under it. +# The same three drawings the iPad and Mac listings show — star, spiral, tree — +# so a shopper comparing the platforms sees the same app rather than three. +# +# One sheet size for all three, and it is a ceiling rather than a preference: +# the paper is nearer than the windows, so a larger one occludes them. 0.8 +# ate the bottom of both the blocks and the code, which is the arrangement +# these captures exist to show. +SHOTS = [ + { name: "1_star_table", sample: "star", draw: 1.0, sheet: "0.7,1.2,0.38" }, + { name: "2_spiral_table", sample: "spiral", draw: 1.0, sheet: "0.7,1.2,0.38" }, + { name: "3_tree_table", sample: "tree", draw: 1.0, sheet: "0.7,1.2,0.38" } +].freeze + +# How long to give a launch before calling it a failure, and how many times to +# try. Three, because the flake has never needed more than a second attempt and +# a run that fails three times is telling you something else. +READY_TIMEOUT = 40 +ATTEMPTS = 3 + +# After `TBReady`, before the shutter. The seek has landed by then; this is the +# canvas redrawing to it and the window settling. +SETTLE = 3 + +def simctl(*arguments) + IO.popen(["xcrun", "simctl", *arguments], &:read) +end + +def device + json = JSON.parse(simctl("list", "devices", "booted", "-j")) + booted = json["devices"].flat_map { |runtime, list| runtime.include?("xrOS") ? list : [] } + abort("No booted visionOS simulator — boot one and try again.") if booted.empty? + + booted.first["udid"] +end + +def app_bundle + bundles = Pathname.glob( + Pathname.new(Dir.home) / + "Library/Developer/Xcode/DerivedData/TortoiseBlocks-*/Build/Products/Debug-xrsimulator/TortoiseBlocks.app" + ) + abort("No visionOS build in DerivedData — build the scheme first.") if bundles.empty? + + bundles.max_by { |path| path.mtime } +end + +# Whether this *process* reported itself ready. Filtering on the pid rather +# than the time is what keeps a previous attempt's marker from being read as +# this one's. +def ready?(udid, pid) + deadline = Time.now + READY_TIMEOUT + while Time.now < deadline + log = IO.popen( + ["xcrun", "simctl", "spawn", udid, "log", "show", "--last", "3m", "--predicate", + "processID == #{pid} AND subsystem == \"#{BUNDLE_ID}\""], &:read + ) + return true if log.include?("TBReady") + return false if log.include?("TBNotReady") + + sleep(2) + end + false +end + +# Reinstalled before every attempt, not once before the run. +# +# visionOS restores an app's windows, so a launch inherits wherever the last +# one left them — and then opens its own on top. That is two blocks windows in +# the frame, one of them near the ceiling, and it is what made the framing +# wander between captures that were supposed to be identical. Uninstalling +# takes the scene state with it, so every shot starts from the same room. +def capture(udid, shot, locale, language) + ATTEMPTS.times do |attempt| + simctl("terminate", udid, BUNDLE_ID) + simctl("uninstall", udid, BUNDLE_ID) + simctl("install", udid, APP_BUNDLE.to_s) + sleep(2) + launched = simctl( + "launch", udid, BUNDLE_ID, + "-TBPlace", "YES", + "-TBSample", shot[:sample], + "-TBDraw", shot[:draw].to_s, + "-TBSheet", shot[:sheet], + "-AppleLanguages", "(#{language})", + "-AppleLocale", language == "ja" ? "ja_JP" : "en_US" + ) + pid = launched[/:\s*(\d+)/, 1] + next warn(" launch failed, retrying") if pid.nil? + + unless ready?(udid, pid.to_i) + warn(" no sheet on attempt #{attempt + 1}, relaunching") + next + end + + sleep(SETTLE) + target = DESTINATION / locale / "#{shot[:name]}.png" + FileUtils.mkdir_p(target.dirname) + simctl("io", udid, "screenshot", target.to_s) + return target + end + abort("#{shot[:name]} (#{locale}) never came up with a sheet after #{ATTEMPTS} attempts") +end + +wanted = ARGV.reject { |argument| argument.start_with?("-") } +shots = wanted.empty? ? SHOTS : SHOTS.select { |shot| wanted.any? { |w| shot[:name].include?(w) } } +abort("Nothing matches #{wanted.join(', ')}") if shots.empty? + +udid = device +APP_BUNDLE = app_bundle +puts "device #{udid}" +puts "app #{APP_BUNDLE}" + +LOCALES.each do |locale, language| + shots.each do |shot| + puts "#{locale}/#{shot[:name]} (#{shot[:sample]}, draw #{shot[:draw]}, sheet #{shot[:sheet]})" + puts " → #{capture(udid, shot, locale, language).relative_path_from(ROOT)}" + end +end + +puts "\nflattening and optimising" +system("ruby", (ROOT / "Tools" / "screenshots.rb").to_s) || abort("screenshots.rb failed") diff --git a/TortoiseBlocks.xcodeproj/project.pbxproj b/TortoiseBlocks.xcodeproj/project.pbxproj index c8e4339..4602a85 100644 --- a/TortoiseBlocks.xcodeproj/project.pbxproj +++ b/TortoiseBlocks.xcodeproj/project.pbxproj @@ -14,6 +14,13 @@ /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ + 14E14162303733DA00A54BD3 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 1A0000000000000000000070 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 1A0000000000000000000020; + remoteInfo = TortoiseBlocks; + }; 1A0000000000000000000072 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 1A0000000000000000000070 /* Project object */; @@ -38,6 +45,7 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 14E1415C303733DA00A54BD3 /* TortoiseBlocksUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TortoiseBlocksUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 1A0000000000000000000010 /* TortoiseBlocks.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = TortoiseBlocks.app; sourceTree = BUILT_PRODUCTS_DIR; }; 1A0000000000000000000011 /* TortoiseBlocksThumbnail.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = TortoiseBlocksThumbnail.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 1A0000000000000000000012 /* Signing.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Signing.xcconfig; sourceTree = ""; }; @@ -52,12 +60,18 @@ isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( Views/CLAUDE.md, + Views/Viewer/CLAUDE.md, ); target = 1A0000000000000000000020 /* TortoiseBlocks */; }; /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ + 14E1415D303733DA00A54BD3 /* TortoiseBlocksUITests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = TortoiseBlocksUITests; + sourceTree = ""; + }; 1A0000000000000000000002 /* App */ = { isa = PBXFileSystemSynchronizedRootGroup; exceptions = ( @@ -74,6 +88,13 @@ /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ + 14E14159303733DA00A54BD3 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 1A0000000000000000000031 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -100,6 +121,7 @@ 1A0000000000000000000002 /* App */, 1A0000000000000000000004 /* ThumbnailExtension */, 1A0000000000000000000005 /* Support */, + 14E1415D303733DA00A54BD3 /* TortoiseBlocksUITests */, 1A0000000000000000000003 /* Products */, ); sourceTree = ""; @@ -109,6 +131,7 @@ children = ( 1A0000000000000000000010 /* TortoiseBlocks.app */, 1A0000000000000000000011 /* TortoiseBlocksThumbnail.appex */, + 14E1415C303733DA00A54BD3 /* TortoiseBlocksUITests.xctest */, ); name = Products; sourceTree = ""; @@ -128,6 +151,29 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + 14E1415B303733DA00A54BD3 /* TortoiseBlocksUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 14E14166303733DA00A54BD3 /* Build configuration list for PBXNativeTarget "TortoiseBlocksUITests" */; + buildPhases = ( + 14E14158303733DA00A54BD3 /* Sources */, + 14E14159303733DA00A54BD3 /* Frameworks */, + 14E1415A303733DA00A54BD3 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 14E14163303733DA00A54BD3 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 14E1415D303733DA00A54BD3 /* TortoiseBlocksUITests */, + ); + name = TortoiseBlocksUITests; + packageProductDependencies = ( + ); + productName = TortoiseBlocksUITests; + productReference = 14E1415C303733DA00A54BD3 /* TortoiseBlocksUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; 1A0000000000000000000020 /* TortoiseBlocks */ = { isa = PBXNativeTarget; buildConfigurationList = 1A0000000000000000000090 /* Build configuration list for PBXNativeTarget "TortoiseBlocks" */; @@ -184,9 +230,13 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 2600; + LastSwiftUpdateCheck = 2660; LastUpgradeCheck = 2660; TargetAttributes = { + 14E1415B303733DA00A54BD3 = { + CreatedOnToolsVersion = 26.6; + TestTargetID = 1A0000000000000000000020; + }; 1A0000000000000000000020 = { CreatedOnToolsVersion = 26.0; }; @@ -216,11 +266,19 @@ targets = ( 1A0000000000000000000020 /* TortoiseBlocks */, 1A0000000000000000000021 /* TortoiseBlocksThumbnail */, + 14E1415B303733DA00A54BD3 /* TortoiseBlocksUITests */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + 14E1415A303733DA00A54BD3 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 1A0000000000000000000032 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -238,6 +296,13 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 14E14158303733DA00A54BD3 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 1A0000000000000000000030 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -255,6 +320,11 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ + 14E14163303733DA00A54BD3 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 1A0000000000000000000020 /* TortoiseBlocks */; + targetProxy = 14E14162303733DA00A54BD3 /* PBXContainerItemProxy */; + }; 1A0000000000000000000071 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 1A0000000000000000000021 /* TortoiseBlocksThumbnail */; @@ -263,6 +333,68 @@ /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ + 14E14164303733DA00A54BD3 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_STYLE = Manual; + CURRENT_PROJECT_VERSION = 1; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.5; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = space.hiraku.tortoiseblocks.uitests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = auto; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SUPPORTED_PLATFORMS = "iphonesimulator macosx"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + TARGETED_DEVICE_FAMILY = 2; + TEST_TARGET_NAME = TortoiseBlocks; + }; + name = Debug; + }; + 14E14165303733DA00A54BD3 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_STYLE = Manual; + CURRENT_PROJECT_VERSION = 1; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.5; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = space.hiraku.tortoiseblocks.uitests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = auto; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SUPPORTED_PLATFORMS = "iphonesimulator macosx"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + TARGETED_DEVICE_FAMILY = 2; + TEST_TARGET_NAME = TortoiseBlocks; + }; + name = Release; + }; 1A0000000000000000000081 /* Debug */ = { isa = XCBuildConfiguration; baseConfigurationReference = 1A0000000000000000000012 /* Signing.xcconfig */; @@ -517,6 +649,15 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + 14E14166303733DA00A54BD3 /* Build configuration list for PBXNativeTarget "TortoiseBlocksUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 14E14164303733DA00A54BD3 /* Debug */, + 14E14165303733DA00A54BD3 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 1A0000000000000000000080 /* Build configuration list for PBXProject "TortoiseBlocks" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/TortoiseBlocks.xcodeproj/xcshareddata/xcschemes/TortoiseBlocks.xcscheme b/TortoiseBlocks.xcodeproj/xcshareddata/xcschemes/TortoiseBlocks.xcscheme index c8bc481..c9735f1 100644 --- a/TortoiseBlocks.xcodeproj/xcshareddata/xcschemes/TortoiseBlocks.xcscheme +++ b/TortoiseBlocks.xcodeproj/xcshareddata/xcschemes/TortoiseBlocks.xcscheme @@ -28,6 +28,18 @@ selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv = "YES" shouldAutocreateTestPlan = "YES"> + + + + + + Shot in + let fields = entry.split(separator: ":") + return Shot( + name: String(fields[0]), sample: String(fields[1]), + pane: Pane(rawValue: String(fields[2])) ?? .canvas) + } + // **One locale per run, on purpose.** Opening a document that is not + // in the app's own folder imports a copy, and the name is + // deduplicated — `star-2` — which then shows in the capture's title + // bar. The driver uninstalls the app before each run, which resets + // that history; two languages in one run would spend it. + let locale = try environment("TB_LOCALE") + let language = try environment("TB_LANGUAGE") + + for shot in shots { + capture(shot, documents: documents, locale: locale, language: language) + } + } + + @MainActor + private func capture(_ shot: Shot, documents: String, locale: String, language: String) { + let app = XCUIApplication() + // The app's own language, for this launch only — so nothing is left + // switched on the device afterwards. + app.launchArguments = [ + "-AppleLanguages", "(\(language))", + "-AppleLocale", language == "ja" ? "ja_JP" : "en_US", + ] + // **macOS reopens the windows it had when it quit.** Each shot ends by + // terminating the app, so without this the next launch restores the + // previous drawing *and* then opens the new one — two windows, two + // transports, and `play.fill` stops being a single element ("Multiple + // matching elements found", which does not mention restoration at + // all). iOS has no equivalent and needs no equivalent. + #if os(macOS) + app.launchArguments += ["-NSQuitAlwaysKeepsWindows", "NO"] + #endif + app.launch() + + // **Close whatever came back with it.** A shot ends by terminating the + // app, and macOS reopens the windows it had when it quit — so the + // second shot's launch restores the first shot's drawing and then + // opens its own beside it. Two windows means two transports, and + // `play.fill` stops being a single element; the failure says "Multiple + // matching elements found" and nothing about restoration. + #if os(macOS) + for window in app.windows.allElementsBoundByIndex where window.exists { + window.buttons[XCUIIdentifierCloseWindow].click() + } + #endif + + // **After the launch, not before.** A device rotated while no app is + // in front comes back portrait when one arrives, so an orientation set + // first is silently undone and every capture lands 2064×2752 — the + // wrong way round for a listing whose other captures are landscape. + // + // A Mac has no orientation, and `XCUIDevice` has no such member there. + #if os(iOS) + XCUIDevice.shared.orientation = .landscapeLeft + #endif + + // Straight to the drawing, rather than tapping through the document + // browser: the browser's own layout is not ours to depend on, and it + // is one more thing that differs between languages. + // + // The documents sit in the *device's* tmp, not in the app's container, + // and that is not a shortcut: preparing the run reinstalls the app, and + // a reinstall gives it a new data container — so anything seeded there + // beforehand is gone by the time this opens it. Outside the container + // the files survive, and LaunchServices grants the app access to what + // it is handed, exactly as it does for a file opened from Files. + let document = URL(fileURLWithPath: documents) + .appendingPathComponent("\(shot.sample).tortoise") + XCUIDevice.shared.system.open(document) + + let scrubber = app.sliders.firstMatch + XCTAssertTrue( + scrubber.waitForExistence(timeout: 30), + "\(locale)/\(shot.name): the document never opened") + + // **The drawing has to be run; there is no shortcut to the end.** The + // scrubber is `Disabled` until something has been run — its value + // reads 「まだ なにも うごかしていません」 — so dragging it there does + // nothing at all, silently, and produces a capture that looks + // perfectly well made of an empty canvas. So does + // `adjust(toNormalizedSliderPosition:)`, and so does `⌘R` + // (`AppCommands`), the simulator having no hardware keyboard attached. + // + // The button is addressed as `play.fill`, the SF Symbol's own name: + // SwiftUI hands it through as the accessibility identifier, so it is + // the same in both languages while the label ("うごかす") is not. + let play = app.buttons["play.fill"] + XCTAssertTrue( + play.waitForExistence(timeout: 15), "\(locale)/\(shot.name): no play button") + play.tap() + + // Then wait for the drawing to stop growing, by watching the + // scrubber's own accessibility value. Better than sleeping for a + // guessed duration: the tree is twice the spiral, so a fixed wait is + // either wrong for one of them or wasteful for all of them. + let idle = scrubber.value as? String + var last = idle + var settled = 0 + let deadline = Date().addingTimeInterval(90) + while Date() < deadline { + Thread.sleep(forTimeInterval: 0.5) + let now = scrubber.value as? String + settled = (now == last) ? settled + 1 : 0 + last = now + // Four quiet polls, and not still the value it had before the run + // started — otherwise "nothing has happened yet" reads as "done". + if settled >= 4, now != idle { break } + } + + if shot.pane == .code { + // Canvas is the first choice and Code the second + // (`CanvasViewToggle`) — an order, not a label. + // + // **The same `Picker(.segmented)` is a different element on each + // platform**: a `SegmentedControl` of buttons on iOS, a + // `RadioGroup` of radio buttons in the toolbar on macOS. Looking + // for the iOS one on a Mac finds nothing and times out saying only + // that there is no toggle. + #if os(macOS) + let toggle = app.radioGroups.firstMatch + XCTAssertTrue( + toggle.waitForExistence(timeout: 10), + "\(locale)/\(shot.name): no pane toggle") + toggle.radioButtons.element(boundBy: 1).click() + #else + let toggle = app.segmentedControls.firstMatch + XCTAssertTrue( + toggle.waitForExistence(timeout: 10), + "\(locale)/\(shot.name): no pane toggle") + toggle.buttons.element(boundBy: 1).tap() + #endif + } + + // The canvas flushes its frames on the next redraw; a capture taken in + // the same runloop turn catches the drawing half-made. + Thread.sleep(forTimeInterval: 2) + + // **The Mac captures the window, not the screen.** What is around it — + // the desktop and the menu bar — is a plate prepared once per language + // and composited under this by the driver, so the capture does not + // depend on what the machine's own desktop happens to look like. On + // iPad the screen *is* the picture. + #if os(macOS) + let screenshot = app.windows.firstMatch.screenshot() + #else + let screenshot = XCUIScreen.main.screenshot() + // Cheap, and it has already caught the one failure that produces a + // perfectly good picture of the wrong thing. + XCTAssertGreaterThan( + screenshot.image.size.width, screenshot.image.size.height, + "\(locale)/\(shot.name): the device did not rotate") + #endif + + let attachment = Self.attachment(for: screenshot) + attachment.name = "\(locale)|\(shot.name)" + attachment.lifetime = .keepAlways + add(attachment) + + app.terminate() + } + + /// The screenshot as an attachment, the right way up. + /// + /// Only iOS needs the redraw below; a Mac window is never held sideways. + private static func attachment(for screenshot: XCUIScreenshot) -> XCTAttachment { + #if os(iOS) + XCTAttachment(data: png(of: screenshot), uniformTypeIdentifier: "public.png") + #else + XCTAttachment(screenshot: screenshot) + #endif + } + + /// The screenshot as a PNG the right way up. + /// + /// `XCTAttachment(screenshot:)` writes the framebuffer as it is held — + /// portrait — and leaves the rotation to a flag on the image, so a + /// landscape capture arrives 2064×2752 with the content on its side. The + /// rotation is baked in here instead, by drawing the image once: `UIImage` + /// reports the *displayed* size, so the redraw comes out 2752×2064 with + /// the pixels to match. + #if os(iOS) + private static func png(of screenshot: XCUIScreenshot) -> Data { + let image = screenshot.image + let format = UIGraphicsImageRendererFormat.default() + format.scale = image.scale + format.opaque = true + + let renderer = UIGraphicsImageRenderer(size: image.size, format: format) + return renderer.image { _ in + image.draw(in: CGRect(origin: .zero, size: image.size)) + }.pngData()! + } + #endif + + /// Passed in by the driver as `TEST_RUNNER_` — **set on xcodebuild's + /// own environment, not as a build setting after the command**, which is + /// accepted, ignored, and arrives nowhere. + private func environment(_ name: String) throws -> String { + guard let value = ProcessInfo.processInfo.environment[name], !value.isEmpty else { + throw XCTSkip("\(name) is not set — run this through Tools/ipad-shots.rb") + } + return value + } +} diff --git a/appstore/metadata-visionos/en-US/description.txt b/appstore/metadata-visionos/en-US/description.txt new file mode 100644 index 0000000..30de66e --- /dev/null +++ b/appstore/metadata-visionos/en-US/description.txt @@ -0,0 +1,23 @@ +Tortoise Blocks is a visual programming app for children. Blocks snap together, a tortoise draws the picture one line at a time, and every drawing is an ordinary document. + +On Apple Vision Pro, Tortoise Blocks is the viewer. Drawings are built on iPad and Mac; this is where you put one down and look at it. + +A DRAWING ON YOUR TABLE +Open a drawing and the app looks for a real surface in the room and lays a sheet of paper on it. Pinch to resize it, twist to turn it, drag it where you want it. If there is no table, it hangs in front of you instead — and you can ask for that on purpose. + +A TORTOISE THAT STANDS ON THE PAPER +The tortoise is a model on the table, not a cursor on a screen. It turns where the line turns, stops where the line stops, and you can lean in and watch it from any side you like. + +WATCH IT DRAW +Play, pause, step forward one command at a time, scrub back and forth, change the speed while it runs. + +THE BLOCKS AND THE CODE, BOTH AT ONCE +Two more windows open beside the drawing: the program it was made from, block by block, and that same program as real Swift code. On iPad and Mac these take turns in one pane, because a screen has to choose. Here nothing has to be put away to see something else. + +SOMETHING TO LOOK AT ON THE FIRST DAY +Four ready-made drawings are built in, so there is a picture for the table before the first file arrives. Drawings are ordinary documents — AirDrop one from an iPad, or open it from Files. + +FOR PARENTS AND TEACHERS +There is no sign-in and no account. There is no advertising, no analytics, no third-party SDK, and no network connection of any kind — the app contains no networking code at all. Nothing about your room is recorded or sent anywhere; the app is told where a surface is so that it can put the paper on it, and that is all. + +Tortoise Blocks runs on iPad, Mac and Apple Vision Pro, speaks English and Japanese, and is free and open source under the MIT license. diff --git a/appstore/metadata-visionos/en-US/keywords.txt b/appstore/metadata-visionos/en-US/keywords.txt new file mode 100644 index 0000000..f0847af --- /dev/null +++ b/appstore/metadata-visionos/en-US/keywords.txt @@ -0,0 +1 @@ +kids,coding,programming,blocks,turtle,graphics,drawing,logo,STEM,education,spatial,3D diff --git a/appstore/metadata-visionos/en-US/marketing_url.txt b/appstore/metadata-visionos/en-US/marketing_url.txt new file mode 100644 index 0000000..e72bfc5 --- /dev/null +++ b/appstore/metadata-visionos/en-US/marketing_url.txt @@ -0,0 +1 @@ +https://temoki.github.io/TortoiseBlocks/?lang=en diff --git a/appstore/metadata-visionos/en-US/name.txt b/appstore/metadata-visionos/en-US/name.txt new file mode 100644 index 0000000..6bf5076 --- /dev/null +++ b/appstore/metadata-visionos/en-US/name.txt @@ -0,0 +1 @@ +Tortoise Blocks diff --git a/appstore/metadata-visionos/en-US/privacy_url.txt b/appstore/metadata-visionos/en-US/privacy_url.txt new file mode 100644 index 0000000..8c41cb6 --- /dev/null +++ b/appstore/metadata-visionos/en-US/privacy_url.txt @@ -0,0 +1 @@ +https://temoki.github.io/TortoiseBlocks/privacy.html?lang=en diff --git a/appstore/metadata-visionos/en-US/promotional_text.txt b/appstore/metadata-visionos/en-US/promotional_text.txt new file mode 100644 index 0000000..0fc3afa --- /dev/null +++ b/appstore/metadata-visionos/en-US/promotional_text.txt @@ -0,0 +1 @@ +Open a drawing on Apple Vision Pro and it lands on the table in front of you, at the size you like, with the tortoise standing on the paper and walking the line. diff --git a/appstore/metadata-visionos/en-US/release_notes.txt b/appstore/metadata-visionos/en-US/release_notes.txt new file mode 100644 index 0000000..421a20b --- /dev/null +++ b/appstore/metadata-visionos/en-US/release_notes.txt @@ -0,0 +1 @@ +Tortoise Blocks comes to Apple Vision Pro, as a viewer for the drawings you make on iPad and Mac. Open one and it goes on the table in front of you, at whatever size you like, with the tortoise walking across the paper as the picture appears. The blocks it was made from and the Swift code behind it open in two more windows, both at once. diff --git a/appstore/metadata-visionos/en-US/subtitle.txt b/appstore/metadata-visionos/en-US/subtitle.txt new file mode 100644 index 0000000..93768b7 --- /dev/null +++ b/appstore/metadata-visionos/en-US/subtitle.txt @@ -0,0 +1 @@ +Snap blocks, draw pictures diff --git a/appstore/metadata-visionos/en-US/support_url.txt b/appstore/metadata-visionos/en-US/support_url.txt new file mode 100644 index 0000000..127b6af --- /dev/null +++ b/appstore/metadata-visionos/en-US/support_url.txt @@ -0,0 +1 @@ +https://github.com/temoki/TortoiseBlocks/issues diff --git a/appstore/metadata-visionos/ja/description.txt b/appstore/metadata-visionos/ja/description.txt new file mode 100644 index 0000000..096f5cd --- /dev/null +++ b/appstore/metadata-visionos/ja/description.txt @@ -0,0 +1,23 @@ +Tortoise Blocks は、子どものためのビジュアルプログラミングアプリです。ブロックを並べるとカメが絵を描き、その作品はふつうの書類として残ります。 + +Apple Vision Pro では、Tortoise Blocks はビューワーです。絵を作るのは iPad と Mac。こちらは、その絵を置いて眺めるための場所です。 + +■ 机の上に広がる絵 +絵を開くと、部屋の中の実際の面を探して、そこに紙を広げます。つまむと大きさが変わり、ひねると向きが変わり、動かせば好きな場所に置けます。机が見つからないときは目の前に浮かびます。はじめからそちらを選ぶこともできます。 + +■ 紙の上に立つカメ +カメは画面の中のカーソルではなく、机の上にいる立体です。線が曲がるところで曲がり、線が止まるところで止まります。近づいて、好きな角度から眺められます。 + +■ 描かれていく様子が見える +再生と一時停止、1コマずつ進める・戻す、シークバーで行き来、途中で速さを変える。 + +■ ブロックもコードも、同時に +絵のとなりに、もう2つのウィンドウが開きます。その絵を作ったプログラムそのものと、同じプログラムを本物の Swift のコードにしたもの。iPad と Mac では1つのペインを切り替えて見るものですが、それは画面がどちらかを選ばなければならないからです。ここでは、何かを見るために何かをしまう必要がありません。 + +■ 初日から見るものがある +みほんの絵が4つ入っています。最初の1枚が届く前から、机に出すものがあります。作品はふつうの書類なので、iPad から AirDrop しても、「ファイル」から開いてもかまいません。 + +■ 保護者・先生の方へ +ログインもアカウントもありません。広告も、利用状況の計測も、第三者製の SDK もありません。ネットワーク通信は一切行わず、そもそもアプリの中に通信を行うコードが含まれていません。部屋の様子はどこにも記録されず、送られもしません。アプリが受け取るのは「面がどこにあるか」だけで、それは紙をそこに置くためです。 + +Tortoise Blocks は iPad と Mac と Apple Vision Pro で動き、日本語と英語に対応しています。MIT ライセンスのオープンソースで、無料です。 diff --git a/appstore/metadata-visionos/ja/keywords.txt b/appstore/metadata-visionos/ja/keywords.txt new file mode 100644 index 0000000..bfd1860 --- /dev/null +++ b/appstore/metadata-visionos/ja/keywords.txt @@ -0,0 +1 @@ +こども,プログラミング,ブロック,タートル,カメ,おえかき,学習,教育,知育,小学生,空間,3D diff --git a/appstore/metadata-visionos/ja/marketing_url.txt b/appstore/metadata-visionos/ja/marketing_url.txt new file mode 100644 index 0000000..24ae321 --- /dev/null +++ b/appstore/metadata-visionos/ja/marketing_url.txt @@ -0,0 +1 @@ +https://temoki.github.io/TortoiseBlocks/?lang=ja diff --git a/appstore/metadata-visionos/ja/name.txt b/appstore/metadata-visionos/ja/name.txt new file mode 100644 index 0000000..6bf5076 --- /dev/null +++ b/appstore/metadata-visionos/ja/name.txt @@ -0,0 +1 @@ +Tortoise Blocks diff --git a/appstore/metadata-visionos/ja/privacy_url.txt b/appstore/metadata-visionos/ja/privacy_url.txt new file mode 100644 index 0000000..615a8f2 --- /dev/null +++ b/appstore/metadata-visionos/ja/privacy_url.txt @@ -0,0 +1 @@ +https://temoki.github.io/TortoiseBlocks/privacy.html?lang=ja diff --git a/appstore/metadata-visionos/ja/promotional_text.txt b/appstore/metadata-visionos/ja/promotional_text.txt new file mode 100644 index 0000000..e6b0678 --- /dev/null +++ b/appstore/metadata-visionos/ja/promotional_text.txt @@ -0,0 +1 @@ +Apple Vision Pro で絵を開くと、目の前の机の上に、好きな大きさで紙が広がります。その紙の上にはカメが立っていて、線を引きながら歩いていきます。 diff --git a/appstore/metadata-visionos/ja/release_notes.txt b/appstore/metadata-visionos/ja/release_notes.txt new file mode 100644 index 0000000..8bbd467 --- /dev/null +++ b/appstore/metadata-visionos/ja/release_notes.txt @@ -0,0 +1 @@ +Apple Vision Pro に対応しました。iPad と Mac で作った絵を見るためのビューワーです。開くと目の前の机の上に好きな大きさで紙が広がり、その上をカメが歩きながら絵を描いていきます。もとになったブロックと、その Swift のコードは、それぞれ別のウィンドウで同時に開けます。 diff --git a/appstore/metadata-visionos/ja/subtitle.txt b/appstore/metadata-visionos/ja/subtitle.txt new file mode 100644 index 0000000..27fe22b --- /dev/null +++ b/appstore/metadata-visionos/ja/subtitle.txt @@ -0,0 +1 @@ +ブロックをならべて、カメに絵をかいてもらおう! diff --git a/appstore/metadata-visionos/ja/support_url.txt b/appstore/metadata-visionos/ja/support_url.txt new file mode 100644 index 0000000..127b6af --- /dev/null +++ b/appstore/metadata-visionos/ja/support_url.txt @@ -0,0 +1 @@ +https://github.com/temoki/TortoiseBlocks/issues diff --git a/appstore/metadata/en-US/description.txt b/appstore/metadata/en-US/description.txt index 7c1f124..81d5555 100644 --- a/appstore/metadata/en-US/description.txt +++ b/appstore/metadata/en-US/description.txt @@ -24,4 +24,4 @@ Drawings are ordinary documents in Files and Finder, and each one shows its own FOR PARENTS AND TEACHERS There is no sign-in and no account. There is nothing to type but numbers. There is no advertising, no analytics, no third-party SDK, and no network connection of any kind — the app contains no networking code at all. Drawings stay on the device and in whatever location you choose to save them. -Tortoise Blocks runs on iPad, Mac and Apple Vision Pro, speaks English and Japanese, and is free and open source under the MIT license. +Tortoise Blocks runs on iPad, Mac and Apple Vision Pro — blocks are built on iPad and Mac, and on Vision Pro a finished drawing goes on the table in front of you. It speaks English and Japanese, and is free and open source under the MIT license. diff --git a/appstore/metadata/en-US/release_notes.txt b/appstore/metadata/en-US/release_notes.txt index 6db97b0..a7a8848 100644 --- a/appstore/metadata/en-US/release_notes.txt +++ b/appstore/metadata/en-US/release_notes.txt @@ -9,4 +9,4 @@ Give a block a name, put whatever you like inside it, and call it from anywhere ON APPLE VISION PRO -Tortoise Blocks now runs on Apple Vision Pro, with the same three panes in a window: the blocks to pick from, the program you are building, and the tortoise drawing it. Your drawings are the same documents on every device. +Tortoise Blocks now runs on Apple Vision Pro, as a viewer for the drawings you make here. Open one and it goes on the table in front of you, at whatever size you like, with the tortoise walking across the paper as the picture appears. Your drawings are the same documents on every device. diff --git a/appstore/metadata/ja/description.txt b/appstore/metadata/ja/description.txt index cac08a2..061bff8 100644 --- a/appstore/metadata/ja/description.txt +++ b/appstore/metadata/ja/description.txt @@ -24,4 +24,4 @@ Tortoise Blocks は、子どものためのビジュアルプログラミング ■ 保護者・先生の方へ ログインもアカウントもありません。入力するのは数字だけです。広告も、利用状況の計測も、第三者製の SDK もありません。ネットワーク通信は一切行わず、そもそもアプリの中に通信を行うコードが含まれていません。作った作品は、端末と、選んだ保存先にだけ残ります。 -Tortoise Blocks は iPad と Mac と Apple Vision Pro で動き、日本語と英語に対応しています。MIT ライセンスのオープンソースで、無料です。 +Tortoise Blocks は iPad と Mac と Apple Vision Pro で動きます。ブロックを組み立てるのは iPad と Mac、Apple Vision Pro では、できあがった絵が目の前の机の上に出ます。日本語と英語に対応し、MIT ライセンスのオープンソースで、無料です。 diff --git a/appstore/metadata/ja/release_notes.txt b/appstore/metadata/ja/release_notes.txt index b71b56c..40a811a 100644 --- a/appstore/metadata/ja/release_notes.txt +++ b/appstore/metadata/ja/release_notes.txt @@ -9,4 +9,4 @@ ■ Apple Vision Pro に対応しました -Apple Vision Pro でも Tortoise Blocks が動くようになりました。ウィンドウの中は同じ3つのペインです。選ぶブロック、組み立てたプログラム、そして絵を描いていくカメ。作品はどの端末でも同じ書類です。 +Apple Vision Pro に対応しました。あちらはビューワーで、ここで作った絵を開くと、目の前の机の上に好きな大きさで紙が広がります。その上をカメが歩きながら、絵を描いていきます。作品はどの端末でも同じ書類です。 diff --git a/appstore/screenshot-sources/macos-plate-en.png b/appstore/screenshot-sources/macos-plate-en.png new file mode 100644 index 0000000..d1d33cf Binary files /dev/null and b/appstore/screenshot-sources/macos-plate-en.png differ diff --git a/appstore/screenshot-sources/macos-plate-ja.png b/appstore/screenshot-sources/macos-plate-ja.png new file mode 100644 index 0000000..c4adc8b Binary files /dev/null and b/appstore/screenshot-sources/macos-plate-ja.png differ diff --git a/appstore/screenshots/ios/en-US/1_star_canvas.png b/appstore/screenshots/ios/en-US/1_star_canvas.png index ce9b591..feb3372 100644 Binary files a/appstore/screenshots/ios/en-US/1_star_canvas.png and b/appstore/screenshots/ios/en-US/1_star_canvas.png differ diff --git a/appstore/screenshots/ios/en-US/2_spiral_canvas.png b/appstore/screenshots/ios/en-US/2_spiral_canvas.png index bfda336..4fccc6a 100644 Binary files a/appstore/screenshots/ios/en-US/2_spiral_canvas.png and b/appstore/screenshots/ios/en-US/2_spiral_canvas.png differ diff --git a/appstore/screenshots/ios/en-US/3_spiral_code.png b/appstore/screenshots/ios/en-US/3_spiral_code.png index 540bad7..c1574ad 100644 Binary files a/appstore/screenshots/ios/en-US/3_spiral_code.png and b/appstore/screenshots/ios/en-US/3_spiral_code.png differ diff --git a/appstore/screenshots/ios/en-US/4_tree_canvas.png b/appstore/screenshots/ios/en-US/4_tree_canvas.png index 3388624..92a5ed6 100644 Binary files a/appstore/screenshots/ios/en-US/4_tree_canvas.png and b/appstore/screenshots/ios/en-US/4_tree_canvas.png differ diff --git a/appstore/screenshots/ios/ja/1_star_canvas.png b/appstore/screenshots/ios/ja/1_star_canvas.png index 28ec69b..5c29418 100644 Binary files a/appstore/screenshots/ios/ja/1_star_canvas.png and b/appstore/screenshots/ios/ja/1_star_canvas.png differ diff --git a/appstore/screenshots/ios/ja/2_spiral_canvas.png b/appstore/screenshots/ios/ja/2_spiral_canvas.png index fdcce4b..2c26950 100644 Binary files a/appstore/screenshots/ios/ja/2_spiral_canvas.png and b/appstore/screenshots/ios/ja/2_spiral_canvas.png differ diff --git a/appstore/screenshots/ios/ja/3_spiral_code.png b/appstore/screenshots/ios/ja/3_spiral_code.png index 5b8fb6a..88f9c2b 100644 Binary files a/appstore/screenshots/ios/ja/3_spiral_code.png and b/appstore/screenshots/ios/ja/3_spiral_code.png differ diff --git a/appstore/screenshots/ios/ja/4_tree_canvas.png b/appstore/screenshots/ios/ja/4_tree_canvas.png index 29d3412..4cd5059 100644 Binary files a/appstore/screenshots/ios/ja/4_tree_canvas.png and b/appstore/screenshots/ios/ja/4_tree_canvas.png differ diff --git a/appstore/screenshots/macos/en-US/1_star_canvas.png b/appstore/screenshots/macos/en-US/1_star_canvas.png index 30eaf02..55d52e1 100644 Binary files a/appstore/screenshots/macos/en-US/1_star_canvas.png and b/appstore/screenshots/macos/en-US/1_star_canvas.png differ diff --git a/appstore/screenshots/macos/en-US/2_spiral_canvas.png b/appstore/screenshots/macos/en-US/2_spiral_canvas.png index 86b626b..e16c669 100644 Binary files a/appstore/screenshots/macos/en-US/2_spiral_canvas.png and b/appstore/screenshots/macos/en-US/2_spiral_canvas.png differ diff --git a/appstore/screenshots/macos/en-US/3_spiral_code.png b/appstore/screenshots/macos/en-US/3_spiral_code.png index ab5b064..1fce40d 100644 Binary files a/appstore/screenshots/macos/en-US/3_spiral_code.png and b/appstore/screenshots/macos/en-US/3_spiral_code.png differ diff --git a/appstore/screenshots/macos/en-US/4_tree_canvas.png b/appstore/screenshots/macos/en-US/4_tree_canvas.png index ce14ce2..9dabecb 100644 Binary files a/appstore/screenshots/macos/en-US/4_tree_canvas.png and b/appstore/screenshots/macos/en-US/4_tree_canvas.png differ diff --git a/appstore/screenshots/macos/ja/1_star_canvas.png b/appstore/screenshots/macos/ja/1_star_canvas.png index 0dfdd4d..1b37b11 100644 Binary files a/appstore/screenshots/macos/ja/1_star_canvas.png and b/appstore/screenshots/macos/ja/1_star_canvas.png differ diff --git a/appstore/screenshots/macos/ja/2_spiral_canvas.png b/appstore/screenshots/macos/ja/2_spiral_canvas.png index 2dc7349..9f2f6ed 100644 Binary files a/appstore/screenshots/macos/ja/2_spiral_canvas.png and b/appstore/screenshots/macos/ja/2_spiral_canvas.png differ diff --git a/appstore/screenshots/macos/ja/3_spiral_code.png b/appstore/screenshots/macos/ja/3_spiral_code.png index bad92fd..c557352 100644 Binary files a/appstore/screenshots/macos/ja/3_spiral_code.png and b/appstore/screenshots/macos/ja/3_spiral_code.png differ diff --git a/appstore/screenshots/macos/ja/4_tree_canvas.png b/appstore/screenshots/macos/ja/4_tree_canvas.png index 55934f0..0c8ec7d 100644 Binary files a/appstore/screenshots/macos/ja/4_tree_canvas.png and b/appstore/screenshots/macos/ja/4_tree_canvas.png differ diff --git a/appstore/screenshots/visionos/en-US/1_star_table.png b/appstore/screenshots/visionos/en-US/1_star_table.png new file mode 100644 index 0000000..d7ca524 Binary files /dev/null and b/appstore/screenshots/visionos/en-US/1_star_table.png differ diff --git a/appstore/screenshots/visionos/en-US/2_spiral_table.png b/appstore/screenshots/visionos/en-US/2_spiral_table.png new file mode 100644 index 0000000..6a5c34f Binary files /dev/null and b/appstore/screenshots/visionos/en-US/2_spiral_table.png differ diff --git a/appstore/screenshots/visionos/en-US/3_tree_table.png b/appstore/screenshots/visionos/en-US/3_tree_table.png new file mode 100644 index 0000000..7f92ffe Binary files /dev/null and b/appstore/screenshots/visionos/en-US/3_tree_table.png differ diff --git a/appstore/screenshots/visionos/ja/1_star_table.png b/appstore/screenshots/visionos/ja/1_star_table.png new file mode 100644 index 0000000..3b5e71c Binary files /dev/null and b/appstore/screenshots/visionos/ja/1_star_table.png differ diff --git a/appstore/screenshots/visionos/ja/2_spiral_table.png b/appstore/screenshots/visionos/ja/2_spiral_table.png new file mode 100644 index 0000000..f77ec4f Binary files /dev/null and b/appstore/screenshots/visionos/ja/2_spiral_table.png differ diff --git a/appstore/screenshots/visionos/ja/3_tree_table.png b/appstore/screenshots/visionos/ja/3_tree_table.png new file mode 100644 index 0000000..5e50fe6 Binary files /dev/null and b/appstore/screenshots/visionos/ja/3_tree_table.png differ diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 3325481..5562a50 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -5,8 +5,19 @@ # upload a binary — skip_binary_upload is on. # # iOS, macOS and visionOS are separate versions in App Store Connect, so each -# takes its own run against its own screenshots directory. The text is shared: -# one directory of files per locale, which is deliver's own layout. +# takes its own run against its own screenshots directory — and, for visionOS, +# its own text. iOS and macOS share appstore/metadata; visionOS is pushed from +# appstore/metadata-visionos, because the App Store shows a Vision Pro shopper +# the visionOS description and nothing else, and the app is a different thing +# there: a viewer for drawings made on the other two, with no editing in it at +# all. A shared description would open by telling that shopper to drag blocks +# into a program, which is the one thing they cannot do. +# +# Three of the fields in those directories are **app**-level in App Store +# Connect — name, subtitle and privacy URL belong to the app, not to a +# platform's version — so every lane writes the same ones and the last run +# wins. They are therefore kept identical between the two directories, and +# metadata_check fails the build if they drift. require_relative "metadata_check" @@ -21,31 +32,37 @@ APP_IDENTIFIER = "space.hiraku.tortoiseblocks" # Absolute, and deliberately so. deliver resolves a relative path against the # repository root — its working directory — not against this file, so # "../appstore/metadata" pointed one level above the checkout. It did not fail: -# it found no locales there, uploaded nothing, and reported success. -METADATA_PATH = File.expand_path("../appstore/metadata", __dir__) +# it found no locales there, uploaded nothing, and reported success. The lanes +# name a directory under this root rather than a path, so there is one place +# that resolution happens. +METADATA_ROOT = File.expand_path("../appstore", __dir__) SCREENSHOTS_ROOT = File.expand_path("../appstore/screenshots", __dir__) +def metadata_path(directory) + File.join(METADATA_ROOT, directory) +end + platform :ios do desc "Diff the live iOS listing against appstore/metadata" lane :metadata_diff do - diff_metadata(platform: "ios") + diff_metadata(platform: "ios", metadata: "metadata") end desc "Push appstore/ to the iOS listing" lane :metadata_push do - push_metadata(platform: "ios", screenshots: "ios") + push_metadata(platform: "ios", screenshots: "ios", metadata: "metadata") end end platform :mac do desc "Diff the live macOS listing against appstore/metadata" lane :metadata_diff do - diff_metadata(platform: "osx") + diff_metadata(platform: "osx", metadata: "metadata") end desc "Push appstore/ to the macOS listing" lane :metadata_push do - push_metadata(platform: "osx", screenshots: "macos") + push_metadata(platform: "osx", screenshots: "macos", metadata: "metadata") end end @@ -56,14 +73,14 @@ end # directory "xros" to match deliver's platform value and every screenshot is # filed under APP_APPLE_TV instead, on an app that has no tvOS listing at all. platform :visionos do - desc "Diff the live visionOS listing against appstore/metadata" + desc "Diff the live visionOS listing against appstore/metadata-visionos" lane :metadata_diff do - diff_metadata(platform: "xros") + diff_metadata(platform: "xros", metadata: "metadata-visionos") end desc "Push appstore/ to the visionOS listing" lane :metadata_push do - push_metadata(platform: "xros", screenshots: "visionos") + push_metadata(platform: "xros", screenshots: "visionos", metadata: "metadata-visionos") end end @@ -101,7 +118,8 @@ private_lane :push_metadata do |options| # An empty or missing directory is the one failure deliver does not report: # it uploads nothing and says it succeeded. Refuse to run instead. screenshots_path = File.join(SCREENSHOTS_ROOT, options[:screenshots]) - [METADATA_PATH, screenshots_path].each do |path| + metadata = metadata_path(options[:metadata]) + [metadata, screenshots_path].each do |path| locales = Dir.glob(File.join(path, "*")).select { |entry| File.directory?(entry) } UI.user_error!("No locale directories under #{path}") if locales.empty? UI.message("#{path}: #{locales.map { |l| File.basename(l) }.sort.join(', ')}") @@ -111,7 +129,7 @@ private_lane :push_metadata do |options| api_key: asc_api_key, app_identifier: APP_IDENTIFIER, platform: options[:platform], - metadata_path: METADATA_PATH, + metadata_path: metadata, screenshots_path: screenshots_path, skip_binary_upload: true, skip_app_version_update: true, @@ -237,7 +255,7 @@ private_lane :diff_metadata do |options| end changes = 0 - Dir.glob(File.join(METADATA_PATH, "*")).sort.each do |directory| + Dir.glob(File.join(metadata_path(options[:metadata]), "*")).sort.each do |directory| next unless File.directory?(directory) locale = File.basename(directory) diff --git a/fastlane/metadata_check.rb b/fastlane/metadata_check.rb index 8978069..6d72fcf 100755 --- a/fastlane/metadata_check.rb +++ b/fastlane/metadata_check.rb @@ -27,6 +27,18 @@ module MetadataCheck URLS = ["privacy_url.txt", "support_url.txt", "marketing_url.txt"].freeze REQUIRED = (LIMITS.keys + URLS).sort.freeze + # The listing text, and there are two sets of it. iOS and macOS share one; + # visionOS has its own, because a Vision Pro shopper is shown the visionOS + # description and nothing else, and the app is a different thing there — a + # viewer for drawings made on the other two, with no editing in it at all. + METADATA_DIRECTORIES = ["metadata", "metadata-visionos"].freeze + + # These three are **app**-level fields in App Store Connect: they belong to + # the app, not to a platform's version, so whichever lane runs last decides + # them for all three listings. Two directories that disagree would make the + # app's name depend on lane order, silently. They have to match. + SHARED = ["name.txt", "subtitle.txt", "privacy_url.txt"].freeze + # Sizes Apple accepts for the display types this app ships. An unexpected # size is a mistake worth stopping on, not a shape to guess at. A platform # missing from this table is not checked at all, so a new screenshots @@ -42,7 +54,18 @@ module MetadataCheck class << self # Every problem as [where, what], sorted. Empty means sendable. def problems(root: DEFAULT_ROOT) - (text_problems(root) + screenshot_problems(root)).sort + (text_problems(root) + shared_problems(root) + screenshot_problems(root)).sort + end + + # Whether App Store Connect would refuse this PNG for its alpha channel. + # + # Public because Tools/screenshots.rb is the thing that *fixes* this, and a + # fixer that disagrees with the gate about what counts as alpha is worse + # than no fixer at all — it would report success on a file this still + # rejects. + def alpha?(path) + info = png_info(Pathname.new(path)) + info && info[2] end # Prints and returns true when the tree is clean. `annotate` turns each @@ -70,9 +93,35 @@ def locale_directories(parent) end def text_problems(root) - locale_directories(root / "metadata").flat_map do |directory| - locale = directory.basename.to_s - REQUIRED.flat_map { |name| field_problems(directory / name, "metadata/#{locale}/#{name}") } + METADATA_DIRECTORIES.flat_map do |metadata| + locale_directories(root / metadata).flat_map do |directory| + locale = directory.basename.to_s + REQUIRED.flat_map { |name| field_problems(directory / name, "#{metadata}/#{locale}/#{name}") } + end + end + end + + # An app-level field that differs between the two directories, reported + # against the one that is not the first — the shared set is the reference, + # since it is what two of the three listings are pushed from. + def shared_problems(root) + reference, *others = METADATA_DIRECTORIES + others.flat_map do |metadata| + locale_directories(root / metadata).flat_map do |directory| + locale = directory.basename.to_s + SHARED.filter_map do |name| + here = directory / name + there = root / reference / locale / name + next unless here.exist? && there.exist? + + a = here.read(encoding: "UTF-8").strip + b = there.read(encoding: "UTF-8").strip + next if a == b + + ["#{metadata}/#{locale}/#{name}", "differs from #{reference}/#{locale}/#{name}, " \ + "but App Store Connect keeps this field on the app rather than the version"] + end + end end end diff --git a/site/index.html b/site/index.html index 2103d83..1e9eb86 100644 --- a/site/index.html +++ b/site/index.html @@ -391,16 +391,22 @@

From blocks to text

-

iPad, Mac and Vision Pro, one app

+

iPad and Mac to build, Vision Pro to look

- The same three panes on all three, at home with each. It is an ordinary - document app: iCloud Drive and Files, autosave, system undo, and - its own folder full of drawings you can tell apart at a glance. + The same three panes on iPad and Mac, at home with each. On Apple + Vision Pro the drawing comes off the screen: open one and it lies + on the table in front of you, at whatever size you like, with the + tortoise standing on the paper and walking the line as it appears. + Underneath, all three are the same ordinary document app — + iCloud Drive and Files, autosave, system undo, and its own folder + full of drawings you can tell apart at a glance.

- Tortoise Blocks running in a window on macOS, drawing an orange star. + A drawing of an orange star lying on a table in a room, seen through
+                Apple Vision Pro, with a small tortoise standing on the paper and the
+                blocks and the Swift code open in windows on either side.
@@ -554,12 +560,12 @@

ブロックから、文字のプログラムへ

-

iPad でも Mac でも Apple Vision Pro でも、同じアプリ

-

3つのペインの構成はどれでも同じで、それぞれの操作にちゃんとなじみます。中身はふつうの書類アプリです。iCloud Drive と「ファイル」、自動保存、システムの取り消し、そして一目で見分けられる作品の並んだフォルダ。

+

作るのは iPad と Mac、見るのは Apple Vision Pro

+

iPad と Mac では3つのペインの構成は同じで、それぞれの操作にちゃんとなじみます。Apple Vision Pro では、絵が画面から出てきます。開くと目の前の机の上に好きな大きさで紙が広がり、その上にカメが立って、線を引きながら歩いていきます。中身はどれも同じ、ふつうの書類アプリです。iCloud Drive と「ファイル」、自動保存、システムの取り消し、そして一目で見分けられる作品の並んだフォルダ。

- macOS のウインドウで動く Tortoise Blocks。オレンジ色の星形を描いている。 + Apple Vision Pro ごしに見た部屋。机の上に広がった紙にオレンジ色の星形が描かれ、その上に小さなカメが立っている。左右にはブロックと Swift のコードのウィンドウ。
diff --git a/site/shots/ipad-en-1.png b/site/shots/ipad-en-1.png index 2c581cc..01cc377 100644 Binary files a/site/shots/ipad-en-1.png and b/site/shots/ipad-en-1.png differ diff --git a/site/shots/ipad-en-2.png b/site/shots/ipad-en-2.png index 24e1c9a..cf9982b 100644 Binary files a/site/shots/ipad-en-2.png and b/site/shots/ipad-en-2.png differ diff --git a/site/shots/ipad-en-4.png b/site/shots/ipad-en-4.png index 4c05a0d..a605ac9 100644 Binary files a/site/shots/ipad-en-4.png and b/site/shots/ipad-en-4.png differ diff --git a/site/shots/ipad-ja-1.png b/site/shots/ipad-ja-1.png index 72254a9..5fd03de 100644 Binary files a/site/shots/ipad-ja-1.png and b/site/shots/ipad-ja-1.png differ diff --git a/site/shots/ipad-ja-2.png b/site/shots/ipad-ja-2.png index 3f41192..48c34a0 100644 Binary files a/site/shots/ipad-ja-2.png and b/site/shots/ipad-ja-2.png differ diff --git a/site/shots/ipad-ja-4.png b/site/shots/ipad-ja-4.png index 9b385ee..c01b772 100644 Binary files a/site/shots/ipad-ja-4.png and b/site/shots/ipad-ja-4.png differ diff --git a/site/shots/mac-en-1.png b/site/shots/mac-en-1.png deleted file mode 100644 index 773eaaa..0000000 Binary files a/site/shots/mac-en-1.png and /dev/null differ diff --git a/site/shots/mac-en-3.png b/site/shots/mac-en-3.png index 5fd9be0..187943f 100644 Binary files a/site/shots/mac-en-3.png and b/site/shots/mac-en-3.png differ diff --git a/site/shots/mac-ja-1.png b/site/shots/mac-ja-1.png deleted file mode 100644 index 6302f66..0000000 Binary files a/site/shots/mac-ja-1.png and /dev/null differ diff --git a/site/shots/mac-ja-3.png b/site/shots/mac-ja-3.png index f1335fb..f8ae78d 100644 Binary files a/site/shots/mac-ja-3.png and b/site/shots/mac-ja-3.png differ diff --git a/site/shots/vision-en-1.png b/site/shots/vision-en-1.png new file mode 100644 index 0000000..99880fd Binary files /dev/null and b/site/shots/vision-en-1.png differ diff --git a/site/shots/vision-ja-1.png b/site/shots/vision-ja-1.png new file mode 100644 index 0000000..4339faf Binary files /dev/null and b/site/shots/vision-ja-1.png differ