diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md index 81a6d6e..26dfd31 100644 --- a/.claude/skills/release/SKILL.md +++ b/.claude/skills/release/SKILL.md @@ -126,8 +126,29 @@ One more vocabulary mismatch to remember: deliver says `osx`, the Connect API says `MAC_OS`, and passing the former to spaceship reports a missing version that plainly exists. +**visionOS is a third listing, spelled three different ways** (#11). It is a +native app on the xrOS SDK, not "Designed for iPad", so App Store Connect gives +it its own platform version — which the app record must carry *before* either +lane will run (`get_edit_app_store_version` returns nil otherwise, and +`metadata_diff` stops with "No editable xros version"). The text is shared with +the other two, as it already was between iOS and macOS; only the screenshots +are per-platform. Then the vocabulary: **`visionos` names the screenshots +directory, `xros` goes to deliver, `VISION_OS` goes to spaceship**, and the +first of those is not a style choice. A Vision Pro capture is 3840×2160, the +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 +IDs the iPhone/iPad build already registered are the ones visionOS signs +against. + **A release is a `v*` tag** (#4). Xcode Cloud runs one `Release` workflow off -it — two Archive actions, iOS and macOS, each with a TestFlight internal +it — an Archive action per platform, each with a TestFlight internal post-action bound to its own archive artifact. It carries no Build or Test action: GitHub Actions has already run the lint, the Kit tests and both platform builds on the way to main, and Xcode Cloud's 25 free compute diff --git a/.github/workflows/appstore-metadata.yml b/.github/workflows/appstore-metadata.yml index 1ec937c..74167a2 100644 --- a/.github/workflows/appstore-metadata.yml +++ b/.github/workflows/appstore-metadata.yml @@ -13,8 +13,8 @@ on: platform: description: Which listing type: choice - options: [both, ios, macos] - default: both + options: [all, ios, macos, visionos] + default: all apply: description: Upload (unticked, only the diff is shown) type: boolean @@ -48,9 +48,10 @@ jobs: run: | lane='${{ inputs.apply && 'metadata_push' || 'metadata_diff' }}' case '${{ inputs.platform }}' in - ios) platforms="ios" ;; - macos) platforms="mac" ;; - *) platforms="ios mac" ;; + ios) platforms="ios" ;; + macos) platforms="mac" ;; + visionos) platforms="visionos" ;; + *) platforms="ios mac visionos" ;; esac for p in $platforms; do echo "::group::$p $lane" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 47ba4a9..b168396 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -99,6 +99,13 @@ jobs: destination: generic/platform=iOS Simulator - name: macOS destination: platform=macOS + # visionOS (#11). It shares every source file with iPadOS, so what + # this catches is the `#if` that quietly stops applying: SwiftUI + # marks a handful of iOS API unavailable there (`ToolbarSpacer`), + # and an `#if os(iOS)` compiles clean on all three platforms while + # doing nothing on this one. + - name: visionOS + destination: generic/platform=visionOS Simulator steps: - uses: actions/checkout@v7 diff --git a/App/Localizable.xcstrings b/App/Localizable.xcstrings index 2ad62fa..b5ea776 100644 --- a/App/Localizable.xcstrings +++ b/App/Localizable.xcstrings @@ -704,6 +704,16 @@ } } }, + "Open Another Drawing" : { + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ほかの えを ひらく" + } + } + } + }, "Order" : { "localizations" : { "ja" : { diff --git a/App/Resources/Assets.xcassets/AppIcon.solidimagestack/Back.solidimagestacklayer/Content.imageset/AppIcon_Back.png b/App/Resources/Assets.xcassets/AppIcon.solidimagestack/Back.solidimagestacklayer/Content.imageset/AppIcon_Back.png new file mode 100644 index 0000000..ad0bfec Binary files /dev/null and b/App/Resources/Assets.xcassets/AppIcon.solidimagestack/Back.solidimagestacklayer/Content.imageset/AppIcon_Back.png differ diff --git a/App/Resources/Assets.xcassets/AppIcon.solidimagestack/Back.solidimagestacklayer/Content.imageset/Contents.json b/App/Resources/Assets.xcassets/AppIcon.solidimagestack/Back.solidimagestacklayer/Content.imageset/Contents.json new file mode 100644 index 0000000..fc68ee6 --- /dev/null +++ b/App/Resources/Assets.xcassets/AppIcon.solidimagestack/Back.solidimagestacklayer/Content.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "filename" : "AppIcon_Back.png", + "idiom" : "vision", + "scale" : "2x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/App/Resources/Assets.xcassets/AppIcon.solidimagestack/Back.solidimagestacklayer/Contents.json b/App/Resources/Assets.xcassets/AppIcon.solidimagestack/Back.solidimagestacklayer/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/App/Resources/Assets.xcassets/AppIcon.solidimagestack/Back.solidimagestacklayer/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/App/Resources/Assets.xcassets/AppIcon.solidimagestack/Contents.json b/App/Resources/Assets.xcassets/AppIcon.solidimagestack/Contents.json new file mode 100644 index 0000000..950af4d --- /dev/null +++ b/App/Resources/Assets.xcassets/AppIcon.solidimagestack/Contents.json @@ -0,0 +1,17 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "layers" : [ + { + "filename" : "Front.solidimagestacklayer" + }, + { + "filename" : "Middle.solidimagestacklayer" + }, + { + "filename" : "Back.solidimagestacklayer" + } + ] +} diff --git a/App/Resources/Assets.xcassets/AppIcon.solidimagestack/Front.solidimagestacklayer/Content.imageset/AppIcon_Front.png b/App/Resources/Assets.xcassets/AppIcon.solidimagestack/Front.solidimagestacklayer/Content.imageset/AppIcon_Front.png new file mode 100644 index 0000000..c70b7dc Binary files /dev/null and b/App/Resources/Assets.xcassets/AppIcon.solidimagestack/Front.solidimagestacklayer/Content.imageset/AppIcon_Front.png differ diff --git a/App/Resources/Assets.xcassets/AppIcon.solidimagestack/Front.solidimagestacklayer/Content.imageset/Contents.json b/App/Resources/Assets.xcassets/AppIcon.solidimagestack/Front.solidimagestacklayer/Content.imageset/Contents.json new file mode 100644 index 0000000..49293b9 --- /dev/null +++ b/App/Resources/Assets.xcassets/AppIcon.solidimagestack/Front.solidimagestacklayer/Content.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "filename" : "AppIcon_Front.png", + "idiom" : "vision", + "scale" : "2x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/App/Resources/Assets.xcassets/AppIcon.solidimagestack/Front.solidimagestacklayer/Contents.json b/App/Resources/Assets.xcassets/AppIcon.solidimagestack/Front.solidimagestacklayer/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/App/Resources/Assets.xcassets/AppIcon.solidimagestack/Front.solidimagestacklayer/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/App/Resources/Assets.xcassets/AppIcon.solidimagestack/Middle.solidimagestacklayer/Content.imageset/AppIcon_Middle.png b/App/Resources/Assets.xcassets/AppIcon.solidimagestack/Middle.solidimagestacklayer/Content.imageset/AppIcon_Middle.png new file mode 100644 index 0000000..b58a0fb Binary files /dev/null and b/App/Resources/Assets.xcassets/AppIcon.solidimagestack/Middle.solidimagestacklayer/Content.imageset/AppIcon_Middle.png differ diff --git a/App/Resources/Assets.xcassets/AppIcon.solidimagestack/Middle.solidimagestacklayer/Content.imageset/Contents.json b/App/Resources/Assets.xcassets/AppIcon.solidimagestack/Middle.solidimagestacklayer/Content.imageset/Contents.json new file mode 100644 index 0000000..a1e19d3 --- /dev/null +++ b/App/Resources/Assets.xcassets/AppIcon.solidimagestack/Middle.solidimagestacklayer/Content.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "filename" : "AppIcon_Middle.png", + "idiom" : "vision", + "scale" : "2x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/App/Resources/Assets.xcassets/AppIcon.solidimagestack/Middle.solidimagestacklayer/Contents.json b/App/Resources/Assets.xcassets/AppIcon.solidimagestack/Middle.solidimagestacklayer/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/App/Resources/Assets.xcassets/AppIcon.solidimagestack/Middle.solidimagestacklayer/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/App/TortoiseBlocksApp.swift b/App/TortoiseBlocksApp.swift index 012d593..c76a310 100644 --- a/App/TortoiseBlocksApp.swift +++ b/App/TortoiseBlocksApp.swift @@ -22,7 +22,7 @@ struct TortoiseBlocksApp: App { // unavailable on macOS and SwiftUI has no empty `Scene` to return in // its place, so this `#if` can't hide inside a modifier the way the // ones in `PlatformModifiers` do. - #if os(iOS) + #if !os(macOS) LaunchScene() #endif } diff --git a/App/Views/CLAUDE.md b/App/Views/CLAUDE.md index a24ea7c..ce0e14f 100644 --- a/App/Views/CLAUDE.md +++ b/App/Views/CLAUDE.md @@ -66,6 +66,60 @@ with `.toolbar(removing: .title)` (#31). The back chevron beside it is not ours to remove — neither dropping that column's toolbar nor `navigationBarBackButtonHidden` touches it. +**visionOS needs three things the other two get for free** (#11), and all three +are `#if os(visionOS)` rather than shared, because on iPadOS and macOS each +would be a second copy of something that already exists. + +*A way back to the browser.* iPadOS puts a chevron beside the document title +and macOS has File ▸ Open with a window per document; visionOS has neither, so +the window carries the drawing it was opened with and the only route to another +was to close it and launch again. `documentBrowserToolbar()` puts a folder +button in the sidebar's bar, and `dismiss` — what a `DocumentGroup` document +closes itself with — is what it calls. **Where `dismiss` is read from decides +whether it does anything.** Read inside the toolbar item's own view, which is +the obvious place, it resolves against the toolbar's context and the button is +inert: it highlights on press and nothing happens. It has to come from the +environment of the *content* the toolbar is attached to, which is why this is a +`ViewModifier` and not a view inside the `toolbar` block. Nothing warns you — +the code compiles and the button draws. + +*The name is deliberately in one place.* The sidebar carries the +DocumentGroup's own title with its rename chevron, and `CanvasPane` still drops +its copy with `.toolbar(removing: .title)` (#31). A second, self-drawn label in +the canvas pane was tried — `\.documentConfiguration`'s `fileURL`, which unlike +the system chrome is right from the first frame — and taken back out: on a +window this wide the two read as one name printed twice rather than as a title +and a reminder. + +*A hover effect, but never on a drag source.* visionOS does not give a button +with a custom `ButtonStyle` the system hover treatment, so without +`pointerHover()` a palette block is the one thing on screen that never lights +up when looked at — and gaze feedback is the whole targeting affordance there. +The catch is that a hover effect and `draggable` **on the same view** segfault +(a `swift_release` inside SwiftUI's update of that view's body, before a window +appears). The palette entry is a drag source, so its hover lives inside +`PaletteBlockButtonStyle` — one level below the `Button` that carries +`draggable` — while every other call site applies it directly. The crash blames +the body, not the modifier, which is why this first read as "`hoverEffect` +crashes on visionOS": it does not, the pairing does. + +**The code pane is paper, not a semantic surface** (#11). It sat on +`.background.secondary`, which resolves to near-white or near-black on iPad and +Mac but to light translucent glass on visionOS — and the syntax colors had +nowhere to stand: system `.purple` and `.blue` are tuned for an opaque backdrop +and `.plain` was `Color.primary`, which is *white* there, so the plain text and +its ground were both light. It is now white, opaque, the same in both +appearances, rounded the same 8 as the canvas — the two swap places inside one +`ZStack`, so pressing the toggle should change the content and nothing else. +The token colors are fixed values measured against white (8.6:1, 8.4:1, 5.1:1, +16.9:1) for the reason the block fills are fixed (#41). Two traps came with it. +The copy button stays *outside* the paper: on it, it needed the ink as a tint +to be legible, and on visionOS the tint went to the button's capsule instead of +its label, leaving a black lozenge with invisible text. And a program narrower +than the pane sat in the middle of it — in a scroll view that scrolls both ways +the content is offered no width to fill, so a `.leading` frame does nothing and +`defaultScrollAnchor(.topLeading)` is what places it. + **A block row is one VoiceOver element, a container header is not** (#1). Swiping a program should say "まえへ、かず 100、じっこうちゅう" once per block, not stop three times, so a simple row is `.accessibilityElement(children: @@ -123,10 +177,24 @@ damage rather than fixing it: with the label served first, `0.6` in the next row broke as "0." over "6", which is worse — a chip holds a number, a name or a colour, all atomic. So `WorkspaceChipButtonStyle` pins its label with `.fixedSize(horizontal: true, vertical: false)`. Priority rather than -`fixedSize` on the *label*, deliberately: a row two levels deep with a long name -(「はこにかける」) genuinely runs out of room, and there the label should still -wrap instead of overflowing its block. Each level of nesting costs 18pt, so no -column width wins that race — the wrap is accepted there. +`fixedSize` on the *label*, deliberately: a row deep enough with a long name +genuinely runs out of room, and there the label should still wrap instead of +overflowing its block. Each level of nesting costs 18pt, so no column width wins +that race for ever — the wrap is the correct last resort, not the working state. + +**And `ideal` is not a starting point on every platform.** macOS and iPadOS 26 +both let the split view's divider be dragged, so there the ideal is only where +the workspace column *opens*. **visionOS has no draggable divider**, so there +the ideal is the width, for good, and the *detail* column absorbs every extra +point the window has. On a 1280pt visionOS window that used to read 360 here +and ~690 on the canvas (#11) — with 「くりかえす 10 かい」 wrapping か/い at the +**top** level, no nesting involved, and 「はこにかける」 splitting in the middle +three levels down. The ideal is now 440, measured against exactly that program: +every row fits on one line at three levels, and the canvas still clears its own +420 ideal. `max` (560) has to stay above `ideal`, or the two platforms that can +drag could only ever drag narrower. Judge a change to it on a *nested Japanese* +program — English fits where 「はこにかける」 does not, and the top-level wrap is +invisible in a flat one. **Drop model**: a `DropGap` between rows carries `(BodyAddress, index)`, so insertion semantics need no y-coordinate math and every mouth — an if's else diff --git a/App/Views/CodePane.swift b/App/Views/CodePane.swift index 71c642d..931627c 100644 --- a/App/Views/CodePane.swift +++ b/App/Views/CodePane.swift @@ -24,6 +24,27 @@ struct CodePane: View { .labelStyle(.titleAndIcon) } .padding(8) + // The code is on paper, the same paper the canvas is on (#11). It + // used to sit on `.background.secondary`, which is a *semantic* + // surface: near-white or near-black on iPad and Mac, and on + // visionOS light translucent glass over a room. The syntax colors + // then had nowhere to stand — system `.purple` and `.blue` are + // tuned for an opaque backdrop, and `.plain` was `Color.primary`, + // which is *white* there, so the plain text and the ground behind + // it were both light. Opaque, named, and the same in both + // appearances, for the reason the block fills are (#41): this is + // the app's surface, not a response to its surroundings. + // + // White rather than an editor's dark theme because the code pane + // and the canvas swap places inside one `ZStack` — the same sheet, + // rounded the same 8, means pressing the toggle changes only the + // *content*. + // + // The paper wraps the code and nothing else. The copy button stays + // outside it, on the pane's own ground with the window's other + // controls: inside, it needed the ink as a tint to be legible, and + // on visionOS the tint went to the *capsule* instead of the label, + // leaving a black lozenge with invisible text on it. ScrollView([.vertical, .horizontal]) { Text(highlightedCode) .font(.system(.callout, design: .monospaced)) @@ -31,12 +52,40 @@ struct CodePane: View { .padding() .frame(maxWidth: .infinity, alignment: .leading) } + // A program narrower than the pane sat in the *middle* of it, which + // is not where source starts. The `.leading` frame above cannot fix + // that on its own: in a scroll view that scrolls both ways the + // content is offered no width to fill, so it takes its own and the + // scroll view centres what is left over. The anchor is what places + // undersized content, on both axes at once. + .defaultScrollAnchor(.topLeading) + .background(Color.white, in: Self.sheet) + .clipShape(Self.sheet) } - .background(.background.secondary) } - /// Colors each `CodeTokenizer` span with a semantic color, so both - /// light and dark mode stay legible. + /// The canvas's shape, so the two panes are the same sheet — see + /// `CanvasPane.sheet`, which this deliberately matches. + private static let sheet = RoundedRectangle(cornerRadius: 8) + + /// Colors each `CodeTokenizer` span with a fixed color. + /// + /// Fixed, not semantic: the pane is white in both appearances, so a color + /// that inverts with the appearance would be picking its contrast against + /// a background it no longer has. These are measured against white — + /// 8.6:1, 8.4:1, 5.1:1 and 16.9:1 — so every kind clears AA at the callout + /// size, and the three accents stay far enough apart in hue to be told + /// apart at a glance. + private func color(for kind: CodeTokenKind) -> Color { + switch kind { + case .keyword: Color(.sRGB, red: 0.604, green: 0.129, blue: 0.588) // #9A2196 + case .number: Color(.sRGB, red: 0.106, green: 0.220, blue: 0.784) // #1B38C8 + case .methodOrProperty: Color(.sRGB, red: 0.031, green: 0.396, blue: 0.435) // #08656F + case .plain: BlockCategory.ink + } + } + + /// Colors each `CodeTokenizer` span, so the pane reads as source. private var highlightedCode: AttributedString { var result = AttributedString() for token in CodeTokenizer.tokenize(code) { @@ -46,15 +95,6 @@ struct CodePane: View { } return result } - - private func color(for kind: CodeTokenKind) -> Color { - switch kind { - case .keyword: .purple - case .number: .blue - case .methodOrProperty: .teal - case .plain: .primary - } - } } /// Shared with the Run menu's "Copy Code" command (#23), so both paths to diff --git a/App/Views/ContentView.swift b/App/Views/ContentView.swift index 1fcfc8b..86d8f4e 100644 --- a/App/Views/ContentView.swift +++ b/App/Views/ContentView.swift @@ -27,12 +27,13 @@ struct ContentView: View { } } -/// The one layout, on both platforms (#29). The app is iPad and Mac only, and -/// compact width — an iPhone, or an iPad window squeezed into Slide Over — is -/// no longer a design target: three panes' worth of information (palette, -/// program, canvas) folded into one 390pt column never came out usable. A -/// window narrow enough to go compact gets `NavigationSplitView`'s own -/// collapse, not a layout of ours. +/// The one layout, on every platform (#29) — iPad, Mac, and the visionOS +/// window, which is a regular-width scene and needs nothing of its own (#11). +/// Compact width — an iPhone, or an iPad window squeezed into Slide Over — is +/// not a design target: three panes' worth of information (palette, program, +/// canvas) folded into one 390pt column never came out usable. A window narrow +/// enough to go compact gets `NavigationSplitView`'s own collapse, not a layout +/// of ours. struct RootView: View { let workspace: WorkspaceEditor let runner: RunnerModel @@ -56,8 +57,21 @@ struct RootView: View { PaletteView(workspace: workspace) .navigationSplitViewColumnWidth(paletteWidth) } content: { + // 440pt is measured, not chosen, and `ideal` carries more weight + // than it looks. macOS and iPadOS both let the divider be dragged, + // so there the ideal is only where the column *opens*; on visionOS + // it can't be, so the ideal is the width, permanently, while the + // detail column absorbs every extra point — a 1280pt visionOS + // window put 360 here and ~690 on the canvas (#11). + // At 360 a three-deep program broke its labels onto two lines, and + // not only at depth: 「くりかえす 10 かい」 wrapped か/い at the top + // level, and 「はこにかける」 split in the middle. 440 fits every one + // of them on one line at three levels of nesting (each level costs + // 18pt), and still leaves the canvas ~600pt — well over its own 420 + // ideal. `max` has to stay above `ideal`, or the two platforms that + // can drag could only ever drag narrower. WorkspaceView(workspace: workspace, runner: runner) - .navigationSplitViewColumnWidth(min: 300, ideal: 360, max: 440) + .navigationSplitViewColumnWidth(min: 300, ideal: 440, max: 560) } detail: { // 280pt keeps the canvas usable (#23) — narrower and its own // playback row starts contesting space with the drawing. @@ -150,18 +164,8 @@ struct CanvasPane: View { // toolbar nor `navigationBarBackButtonHidden` touches it. .toolbar(removing: .title) .toolbar { - ToolbarSpacer(.flexible, placement: .primaryAction) - - ToolbarItemGroup(placement: .primaryAction) { - CanvasViewToggle(showsCode: $showsCode) - } - - ToolbarSpacer(.fixed, placement: .primaryAction) - - ToolbarItemGroup(placement: .primaryAction) { - CanvasRollAgainButton(workspace: workspace, runner: runner) - CanvasExportMenu(runner: runner, onExport: export) - } + CanvasToolbar( + workspace: workspace, runner: runner, showsCode: $showsCode, onExport: export) } // One alert, switching on why the run failed (`expansionAlert`): // attaching a second one for the recursion case would silently drop @@ -197,6 +201,39 @@ struct CanvasPane: View { } } +/// `CanvasPane`'s toolbar: the canvas/code toggle, then ⟳ and the export menu. +/// +/// It is a `ToolbarContent` type of its own only so the `#if` below has +/// somewhere to live that isn't the call site — `ToolbarSpacer` is the Liquid +/// Glass grouping separator and is unavailable on visionOS, which lays its +/// toolbar out as an ornament and spaces the groups itself. The items and +/// their order are the same everywhere; only the separators come and go. +struct CanvasToolbar: ToolbarContent { + let workspace: WorkspaceEditor + let runner: RunnerModel + @Binding var showsCode: Bool + let onExport: (Data?, UTType) -> Void + + var body: some ToolbarContent { + #if !os(visionOS) + ToolbarSpacer(.flexible, placement: .primaryAction) + #endif + + ToolbarItemGroup(placement: .primaryAction) { + CanvasViewToggle(showsCode: $showsCode) + } + + #if !os(visionOS) + ToolbarSpacer(.fixed, placement: .primaryAction) + #endif + + ToolbarItemGroup(placement: .primaryAction) { + CanvasRollAgainButton(workspace: workspace, runner: runner) + CanvasExportMenu(runner: runner, onExport: onExport) + } + } +} + /// The canvas/code segmented toggle, in `CanvasPane`'s toolbar (#23). struct CanvasViewToggle: View { @Binding var showsCode: Bool diff --git a/App/Views/LaunchScene.swift b/App/Views/LaunchScene.swift index 6031154..a7dcc0e 100644 --- a/App/Views/LaunchScene.swift +++ b/App/Views/LaunchScene.swift @@ -1,11 +1,12 @@ -#if os(iOS) +#if !os(macOS) import SwiftUI /// The screen in front of the system document browser (#32). /// - /// `DocumentGroupLaunchScene` is `@available(macOS, unavailable)`, so this - /// whole file is iOS-only and the Mac keeps the standard open panel. + /// `DocumentGroupLaunchScene` is `@available(macOS, unavailable)` and + /// nothing else, so this file covers iPadOS and visionOS while the Mac + /// keeps the standard open panel. struct LaunchScene: Scene { var body: some Scene { DocumentGroupLaunchScene( diff --git a/App/Views/PaletteView.swift b/App/Views/PaletteView.swift index 1ac7cca..f0f4128 100644 --- a/App/Views/PaletteView.swift +++ b/App/Views/PaletteView.swift @@ -189,9 +189,64 @@ struct PaletteView: View { } .padding() } + .documentBrowserToolbar() } } +extension View { + /// The way back to the document browser, beside the document's own title + /// (#11). visionOS only, so the `#if` hides in a modifier rather than + /// sitting at the call site. + /// + /// Every other platform already has a way and would end up with two: + /// iPadOS puts a chevron next to the title, macOS has File ▸ Open and one + /// window per document. **visionOS has neither.** Its window carries the + /// document it was opened with, nothing offers another, and the only route + /// to a second drawing was closing the window and launching the app again. + func documentBrowserToolbar() -> some View { + #if os(visionOS) + modifier(DocumentBrowserToolbar()) + #else + self + #endif + } +} + +#if os(visionOS) + + /// Closes this document, which leaves the browser it was opened from. + /// + /// `dismiss` is what a `DocumentGroup`'s document closes itself with, and + /// **where it is read from decides whether it does anything.** Read inside + /// the toolbar item's own view — the obvious place, since that is where the + /// button is — it resolves against the toolbar's context and the button is + /// simply inert: it highlights, and nothing happens. It has to come from + /// the environment of the *content* the toolbar is attached to, which is + /// what makes this a `ViewModifier` rather than a view inside the + /// `toolbar` block. The failure is silent in the worst way — the code + /// compiles, the button draws, and only pressing it tells you. + /// + /// Deliberately not paired with a "new drawing" button: the browser's own ⊕ + /// is right there once you are back, and reaching the browser at all is the + /// part that was missing. + private struct DocumentBrowserToolbar: ViewModifier { + @Environment(\.dismiss) private var dismiss + + func body(content: Content) -> some View { + content.toolbar { + ToolbarItem(placement: .navigation) { + Button("Open Another Drawing", systemImage: "folder") { + dismiss() + } + .labelStyle(.iconOnly) + .touchTarget() + } + } + } + } + +#endif + struct PaletteSectionView: View { let section: PaletteSection let workspace: WorkspaceEditor @@ -227,6 +282,11 @@ private struct PaletteBlockButtonStyle: ButtonStyle { .frame(maxWidth: .infinity, alignment: .leading) .background(color, in: RoundedRectangle(cornerRadius: 8)) .opacity(configuration.isPressed ? 0.7 : 1) + // Here rather than on the `Button`, because the button is a drag + // source and the pair crashes visionOS — see `pointerHover`. It + // also reads better: the highlight follows the shape the style + // draws instead of the label's own bounds. + .pointerHover() } } @@ -252,7 +312,9 @@ struct PaletteEntryButton: View { // color — white — which is the one thing a pastel fill can't carry. A // palette entry and the row it becomes should look alike anyway. .buttonStyle(PaletteBlockButtonStyle(color: category.color)) - .pointerHover() + // The hover is inside that style, not here. On visionOS a hover effect + // and `draggable` on the same view segfault — see `pointerHover`. + // // Evaluated per drag, so every drag stamps a fresh Block (new ID). .draggable(Block(kind: entry.kind)) .accessibilityHint("Tap to add to the end of the program. Drag to place anywhere.") diff --git a/App/Views/PlatformModifiers.swift b/App/Views/PlatformModifiers.swift index 7bd8fe1..f7a553b 100644 --- a/App/Views/PlatformModifiers.swift +++ b/App/Views/PlatformModifiers.swift @@ -2,30 +2,57 @@ import SwiftUI // Small cross-cutting modifiers that hide their `#if os(...)` inside a // modifier (the SwiftUI-way rule), so call sites stay platform-agnostic. +// +// They are written `#if !os(macOS)` rather than naming iOS wherever the +// behaviour is simply "the touch platforms": these are UIKit-backed and exist +// on visionOS as well as iPadOS (#11), and spelling the condition as "not the +// Mac" is what keeps a new platform from silently taking the no-op branch the +// way visionOS did — an `#if os(iOS)` compiles clean everywhere and just stops +// applying. `pointerHover` is the exception, and says why. extension View { - /// The number pad for numeric entry on iOS (#24); a no-op elsewhere. - /// `.decimalPad` gives the digits and decimal point kids need; the - /// number blocks don't take negative literals, so its lack of a minus - /// key is intentional. + /// The number pad for numeric entry (#24); a no-op on macOS, which has a + /// hardware keyboard. `.decimalPad` gives the digits and decimal point kids + /// need; the number blocks don't take negative literals, so its lack of a + /// minus key is intentional. func numericKeyboard() -> some View { - #if os(iOS) + #if !os(macOS) keyboardType(.decimalPad) #else self #endif } - /// The iPad (pointer) hover highlight (#24); a no-op on macOS, which has - /// its own cursor affordances. + /// The hover highlight (#24): the iPad's pointer, and on visionOS the gaze, + /// which is the only thing there that says what you are about to press. + /// A no-op on macOS, which has its own cursor affordances. + /// + /// **Never put this on a view that is also `draggable`.** That combination + /// segfaults on visionOS — a `swift_release` inside SwiftUI's own update of + /// the view's body, before a window is ever shown, with `.automatic` as + /// well as `.highlight`. It cost a while to place, because the crash blames + /// the body rather than the modifier and the effect looked like the + /// culprit; the effect is fine and the *pairing* is not. So a palette + /// entry, which is a drag source, wears its hover inside + /// `PaletteBlockButtonStyle` — on the shaped body the style draws, one + /// level below the `Button` that `draggable` is attached to — and that is + /// why the palette applies this in its style while everything else applies + /// it at the call site. + /// + /// visionOS needs it stated at all because it does *not* give a button with + /// a custom `ButtonStyle` the system hover treatment; without this, a + /// palette block is the one thing on screen that never lights up when + /// looked at. func pointerHover() -> some View { - #if os(iOS) + #if !os(macOS) hoverEffect(.highlight) #else self #endif } - /// Holds an icon-only control to the 44pt finger minimum on iPadOS. + /// Holds an icon-only control to the 44pt finger minimum on iPadOS (and to + /// the same floor on visionOS, where the target is a gaze rather than a + /// finger — 44 is the iPad number, not a measured visionOS one). /// A borderless SF Symbol button is only as tappable as the glyph is big — /// around 24pt at body size — so the ⋯ on a block row is a small target on /// a touch screen even though the row around it is not. @@ -44,7 +71,7 @@ extension View { /// this is used sits after a `Spacer`, so the extra width takes slack /// instead of pushing the label. func touchTarget() -> some View { - #if os(iOS) + #if !os(macOS) frame(minWidth: 44, minHeight: 44).contentShape(.rect) #else self diff --git a/CLAUDE.md b/CLAUDE.md index f395117..18eb211 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -303,7 +303,8 @@ And a macOS QuickLook extension has to be sandboxed to be loaded, so it carries even though the app itself has none. `pluginkit -mAvvv | grep -i tortoise` confirms registration — the `-p com.apple.quicklook.thumbnail` filter does not match it and will make a working extension look missing. -`TARGETED_DEVICE_FAMILY` is `"2"` — iPad and Mac, no iPhone (#29). +`TARGETED_DEVICE_FAMILY` is `"2,7"` — iPad, Mac and Vision Pro, no iPhone +(#29, #11). Documents are `.tortoise` files, but the exported UTI keeps the `tortoiseblocks` spelling (`space.hiraku.tortoiseblocks.project`, and `.block` for the drag payload), which is also the bundle ID's — the @@ -356,8 +357,18 @@ greps `project.pbxproj` for the setting, because a build setting cannot enforce its own absence and Xcode writes one there the moment a team is picked in Signing & Capabilities. The everyday loop needs no team because **Debug ad-hoc-signs** -(`CODE_SIGN_IDENTITY = "-"` on macOS for both targets). Release does not, and -must not: those pins used to sit in Release too, which quietly made the +(`CODE_SIGN_IDENTITY = "-"` on macOS for both targets). Note *on macOS* — the +identity is `[sdk=macosx*]`-conditional, and it has to be written that way +round. The extension had it as a bare `CODE_SIGN_IDENTITY = "-"` with an +`[sdk=iphoneos*]` exception naming a real certificate, which reads the same +until a platform arrives that the exception doesn't name: a visionOS *device* +build then fell into the ad-hoc default and stopped with "has entitlements +that require signing with a development certificate" (both targets are +sandboxed, so ad-hoc is never enough on a device). Simulators hid it, because +they ad-hoc sign whatever they are given. Name the platform that wants ad-hoc, +never the ones that don't — the same rule as `#if !os(macOS)` in +`PlatformModifiers`, and the same silent failure when it is inverted. +Release does not ad-hoc sign, and must not: those pins used to sit in Release too, which quietly made the distribution configuration unable to archive at all — an ad-hoc macOS app cannot go to App Store Connect. Release is left to automatic signing, which is what Xcode Cloud's cloud signing then takes over; that is the whole reason @@ -370,6 +381,44 @@ rules apply. The app takes `files.user-selected.read-write` for the `read-only` and is sandboxed for a different reason (a macOS QuickLook extension is not loaded otherwise). +**visionOS runs the iPad app, not a port** (#11). `SUPPORTED_PLATFORMS` gains +`xros xrsimulator`, `XROS_DEPLOYMENT_TARGET` is 26.0, and the same three-pane +`NavigationSplitView` fills the window — the scene is regular width, so nothing +in the layout is platform-conditional and no ornament or volumetric anything is +declared. Both packages already shipped `.visionOS(.v26)`. What the platform +actually costs is the `#if`s, in both directions. **A guard written +`#if os(iOS)` stops applying** — it still compiles everywhere, so `LaunchScene` +(`DocumentGroupLaunchScene` is unavailable on *macOS* only) and the numeric +keyboard and touch targets in `PlatformModifiers` were silently dropped until +they were rewritten as `#if !os(macOS)`. And some UIKit-era API is genuinely +gone: `ToolbarSpacer` — the Liquid Glass grouping separator — is unavailable, +which is the only reason `CanvasToolbar` exists as a `ToolbarContent` type of +its own. That is also why CI builds visionOS: nothing else catches an `#if` +that quietly does nothing. +One layout constant *is* load-bearing here, though the layout itself isn't +conditional: visionOS has **no draggable split-view divider** — macOS and +iPadOS 26 both do — so the workspace column is stuck at its `ideal` for good +while the canvas takes the rest of a wide window. That is what set the ideal at +440; the measurement is in `App/Views/CLAUDE.md`. +One thing is known-missing rather than done: **`hoverEffect` crashes** there — +`.automatic` as well as `.highlight`, a `swift_release` segfault inside +SwiftUI's update of `PaletteEntryButton.body`, before a window appears — so +`pointerHover()` stays iOS-only and says so. + +**The app icon comes from a second, differently-shaped source.** visionOS wants +a circular layered icon and Icon Composer writes only squares (plus watchOS +circles), so `AppIcon.icon` produces nothing for it — +`Assets.xcassets/AppIcon.solidimagestack` does, three +`.solidimagestacklayer`s (Front / Middle / Back) of 1024×1024 at the `vision` +idiom. The two carry the same name on purpose and do **not** collide: +`ASSETCATALOG_COMPILER_APPICON_NAME` is `AppIcon` for every platform and actool +routes by idiom, so the visionOS `Assets.car` gets a `SolidImageStack` and no +`IconImageStack`, iOS gets the reverse, and macOS still gets `AppIcon.icns`. +Check a change here in the *built* product rather than in Xcode — `assetutil +--info` on each platform's `Assets.car` — because a stack that never made it in +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. + **Releasing, the store listing and the website are in the `release` skill.** Tags, Xcode Cloud, TestFlight, `appstore/`, fastlane, and `site/`. **Localization**: `en` is the source language; Japanese (kid-friendly diff --git a/README.md b/README.md index da35bb0..1c4f0a0 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![Swift](https://img.shields.io/badge/Swift-6.2-orange.svg)](https://swift.org) [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) -[![Platform](https://img.shields.io/badge/platform-iPadOS%2026%2B%20%7C%20macOS%2026%2B-lightgrey.svg)]() +[![Platform](https://img.shields.io/badge/platform-iPadOS%2026%2B%20%7C%20macOS%2026%2B%20%7C%20visionOS%2026%2B-lightgrey.svg)]() A visual programming app for kids — snap blocks together, press play, and watch the tortoise draw. Powered by @@ -56,7 +56,8 @@ graphics engine written in Swift. ## Requirements - **Xcode** 26+ (Swift 6.2) -- **Platforms** iPadOS 26+ · macOS 26+ (visionOS planned) +- **Platforms** iPadOS 26+ · macOS 26+ · visionOS 26+ (the same three-pane app, + in a window on Vision Pro) ## Getting Started @@ -171,11 +172,11 @@ arithmetic saturates the same way, so a value can never run off to infinity. ## Releasing A `v*` tag is the release. Pushing one starts an Xcode Cloud workflow that -archives both platforms and sends them to TestFlight, while GitHub Actions -checks that the tag matches `MARKETING_VERSION` in every configuration — the -two are otherwise unconnected, and a mismatch would ship the wrong version -silently. The same tag drafts a GitHub release, with notes split by whether a -commit reached the app or only the site, the listing, CI or the docs. +archives the app for each platform and sends the builds to TestFlight, while +GitHub Actions checks that the tag matches `MARKETING_VERSION` in every configuration +— the two are otherwise unconnected, and a mismatch would ship the wrong +version silently. The same tag drafts a GitHub release, with notes split by +whether a commit reached the app or only the site, the listing, CI or the docs. The store listing is not part of that. It lives in [appstore/](appstore/) and goes up on demand, by hand: diff --git a/TortoiseBlocks.xcodeproj/project.pbxproj b/TortoiseBlocks.xcodeproj/project.pbxproj index ae216f2..97a938a 100644 --- a/TortoiseBlocks.xcodeproj/project.pbxproj +++ b/TortoiseBlocks.xcodeproj/project.pbxproj @@ -394,6 +394,8 @@ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; "INFOPLIST_KEY_UISupportsDocumentBrowser[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UISupportsDocumentBrowser[sdk=iphonesimulator*]" = YES; + "INFOPLIST_KEY_UISupportsDocumentBrowser[sdk=xros*]" = YES; + "INFOPLIST_KEY_UISupportsDocumentBrowser[sdk=xrsimulator*]" = YES; IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; @@ -402,9 +404,10 @@ PRODUCT_BUNDLE_IDENTIFIER = space.hiraku.tortoiseblocks; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; SWIFT_EMIT_LOC_STRINGS = YES; - TARGETED_DEVICE_FAMILY = 2; + TARGETED_DEVICE_FAMILY = "2,7"; + XROS_DEPLOYMENT_TARGET = 26.0; }; name = Debug; }; @@ -429,6 +432,8 @@ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; "INFOPLIST_KEY_UISupportsDocumentBrowser[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UISupportsDocumentBrowser[sdk=iphonesimulator*]" = YES; + "INFOPLIST_KEY_UISupportsDocumentBrowser[sdk=xros*]" = YES; + "INFOPLIST_KEY_UISupportsDocumentBrowser[sdk=xrsimulator*]" = YES; IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; @@ -437,9 +442,10 @@ PRODUCT_BUNDLE_IDENTIFIER = space.hiraku.tortoiseblocks; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; SWIFT_EMIT_LOC_STRINGS = YES; - TARGETED_DEVICE_FAMILY = 2; + TARGETED_DEVICE_FAMILY = "2,7"; + XROS_DEPLOYMENT_TARGET = 26.0; }; name = Release; }; @@ -447,8 +453,7 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_ENTITLEMENTS = Support/ThumbnailExtension.entitlements; - CODE_SIGN_IDENTITY = "-"; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEAD_CODE_STRIPPING = YES; @@ -470,9 +475,10 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; SKIP_INSTALL = YES; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; SWIFT_EMIT_LOC_STRINGS = YES; - TARGETED_DEVICE_FAMILY = 2; + TARGETED_DEVICE_FAMILY = "2,7"; + XROS_DEPLOYMENT_TARGET = 26.0; }; name = Debug; }; @@ -501,9 +507,10 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; SKIP_INSTALL = YES; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; SWIFT_EMIT_LOC_STRINGS = YES; - TARGETED_DEVICE_FAMILY = 2; + TARGETED_DEVICE_FAMILY = "2,7"; + XROS_DEPLOYMENT_TARGET = 26.0; }; name = Release; }; diff --git a/appstore/README.md b/appstore/README.md index 3a90d02..9ef063d 100644 --- a/appstore/README.md +++ b/appstore/README.md @@ -5,14 +5,24 @@ App Store Connect (#42). ``` metadata//*.txt the text, one file per field -screenshots/// ios/ and macos/, one directory per locale +screenshots/// ios/, macos/ and visionos/, one directory per locale screenshot-sources/ the documents the captures were shot from ``` `` is an App Store Connect locale code (`en-US`, `ja`), not the app's -`en` / `ja` string-catalog code. `ios` and `macos` name the two listings: there -is no ipadOS — iPad is a display type under iOS, and deliver files a screenshot -by its pixel size. +`en` / `ja` string-catalog code. `ios`, `macos` and `visionos` name the three +listings: there is no ipadOS — iPad is a display type under iOS, and deliver +files a screenshot by its pixel size. + +**`visionos` is the one directory name that is load-bearing.** A Vision Pro +screenshot is 3840×2160 — the same size as an Apple TV one — so deliver cannot +tell the two display types apart by size and breaks the tie on whether the +*path* contains `vision`, downcased, anywhere in it. Naming the directory +`xros` to match deliver's platform value would file every capture under +`APP_APPLE_TV`, on an app with no tvOS listing at all. visionOS is spelled +three different ways across this pipeline and all three are required: +`visionos` here (the ASC `Platform` enum, lowercased, like `ios` and `macos`), +`xros` to deliver, `VISION_OS` to spaceship. ## Running it @@ -24,11 +34,19 @@ export ASC_KEY_ID=… export ASC_PRIVATE_KEY_PATH=~/…/AuthKey_XXXXXXXXXX.p8 bundle install -bundle exec fastlane metadata_check # the files alone, no network, no key -bundle exec fastlane ios metadata_diff # what is live, against what is written -bundle exec fastlane ios metadata_push # upload (mac for the other listing) +bundle exec fastlane metadata_check # the files alone, no network, no key +bundle exec fastlane ios metadata_diff # what is live, against what is written +bundle exec fastlane ios metadata_push # upload +bundle exec fastlane mac metadata_push # …and the same two for the other +bundle exec fastlane visionos metadata_push # two listings ``` +A listing only exists once its platform does: `metadata_diff` and +`metadata_push` both need an **editable version** for that platform in App +Store Connect, and for visionOS that means the app record has to carry the +Apple Vision Pro platform first. Until then the lane stops with "No editable +xros version — create one first", which is the guard working, not a bug. + In CI it is the **App Store Metadata** workflow, run by hand from the Actions tab: pick a platform, and tick *apply* to upload rather than diff. It reads the same three values from secrets, with the .p8 base64-encoded into @@ -86,8 +104,11 @@ hand-run upload cannot skip it. It is plain Ruby with no gems, so CI runs it as ## Screenshots - The sizes are the ones Apple accepts as-is: **iPad 13-inch landscape - 2752×2064** and **Mac 2880×1800**. A reshoot has to keep the window sizes - that produced them + 2752×2064**, **Mac 2880×1800** and **Apple Vision Pro 3840×2160**. A reshoot + has to keep the window sizes that produced them. The Vision Pro size needs no + arranging at all — `xcrun simctl io screenshot` on the visionOS + simulator writes exactly 3840×2160 — but the file it writes **has an alpha + channel**, so it still needs `-alpha off` like every other capture - Order comes from the leading number in the filename. Ten per locale, at most - **Carry no alpha channel** (`magick -alpha off -define png:color-type=2 `). Fully opaque is not enough — the channel alone can get a screenshot @@ -95,7 +116,10 @@ hand-run upload cannot skip it. It is plain Ruby with no gems, so CI runs it as - Capture the whole screen on Mac, not the window: a window-only capture has transparent rounded corners and shadow. Set the *system* language to the locale being shot, too — switching only the app's language leaves the menu - bar in the other language + bar in the other language. On Vision Pro the whole "screen" is the simulated + room, so the app window sits in the middle of a living room — that is what + the platform's screenshots look like, and cropping to the window would give + a size Apple does not accept - **deliver uploads every screenshot twice on a first upload**, reproducibly. It matches local against live by MD5, and Apple has not computed that checksum seven seconds after the PUT, so each image looks missing and the diff --git a/appstore/metadata/en-US/description.txt b/appstore/metadata/en-US/description.txt index 3fe3884..7c1f124 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 and Mac, speaks English and Japanese, and is free and open source under the MIT license. +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/en-US/release_notes.txt b/appstore/metadata/en-US/release_notes.txt index 734b923..6db97b0 100644 --- a/appstore/metadata/en-US/release_notes.txt +++ b/appstore/metadata/en-US/release_notes.txt @@ -6,3 +6,7 @@ Give a block a name, put whatever you like inside it, and call it from anywhere • Every block now carries one ⋯ menu: delete it, move it up or down, or give an "if" its "otherwise". • Tap the trash can to clear the whole program at once. You can undo it. • Block labels stay on one line in narrower windows. + +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. diff --git a/appstore/metadata/ja/description.txt b/appstore/metadata/ja/description.txt index b523621..cac08a2 100644 --- a/appstore/metadata/ja/description.txt +++ b/appstore/metadata/ja/description.txt @@ -24,4 +24,4 @@ Tortoise Blocks は、子どものためのビジュアルプログラミング ■ 保護者・先生の方へ ログインもアカウントもありません。入力するのは数字だけです。広告も、利用状況の計測も、第三者製の SDK もありません。ネットワーク通信は一切行わず、そもそもアプリの中に通信を行うコードが含まれていません。作った作品は、端末と、選んだ保存先にだけ残ります。 -Tortoise Blocks は iPad と Mac で動き、日本語と英語に対応しています。MIT ライセンスのオープンソースで、無料です。 +Tortoise Blocks は iPad と Mac と Apple Vision Pro で動き、日本語と英語に対応しています。MIT ライセンスのオープンソースで、無料です。 diff --git a/appstore/metadata/ja/release_notes.txt b/appstore/metadata/ja/release_notes.txt index 7eedd13..b71b56c 100644 --- a/appstore/metadata/ja/release_notes.txt +++ b/appstore/metadata/ja/release_notes.txt @@ -6,3 +6,7 @@ ・ブロックの操作を「⋯」メニューにまとめました(けす/うえへ/したへ/そうでなければ)。 ・ゴミ箱をタップすると、プログラムを一度に消せます。元に戻せます。 ・せまい画面でも、ブロックの文字が折り返さなくなりました。 + +■ Apple Vision Pro に対応しました + +Apple Vision Pro でも Tortoise Blocks が動くようになりました。ウィンドウの中は同じ3つのペインです。選ぶブロック、組み立てたプログラム、そして絵を描いていくカメ。作品はどの端末でも同じ書類です。 diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 9bbd1d5..3325481 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -4,12 +4,18 @@ # Builds reach TestFlight from Xcode Cloud on a v* tag, and these lanes never # upload a binary — skip_binary_upload is on. # -# iOS and macOS 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. +# 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. require_relative "metadata_check" +# fastlane knows :ios, :mac and :android as Fastfile platforms and warns on +# every run about anything else. deliver understands visionOS perfectly well +# (its platform value is "xros"); it is only the namespace around the lanes +# that has to be declared. +Fastlane::SupportedPlatforms.extra = [:visionos] + APP_IDENTIFIER = "space.hiraku.tortoiseblocks" # Absolute, and deliberately so. deliver resolves a relative path against the @@ -43,6 +49,24 @@ platform :mac do end end +# The screenshots directory is "visionos" and it is not free to rename (#11). +# A Vision Pro screenshot is 3840x2160 — the same size as an Apple TV one — so +# deliver cannot tell the two display types apart by size, and breaks the tie +# on whether the *path* contains "vision" (downcased, anywhere in it). Call the +# 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" + lane :metadata_diff do + diff_metadata(platform: "xros") + end + + desc "Push appstore/ to the visionOS listing" + lane :metadata_push do + push_metadata(platform: "xros", screenshots: "visionos") + end +end + desc "Check appstore/ without touching the network" lane :metadata_check do check_metadata @@ -185,11 +209,14 @@ APP_FIELDS = { }.freeze # deliver and spaceship do not share a vocabulary for the platforms: deliver -# takes "osx", the Connect API calls the same thing MAC_OS. The lanes speak -# deliver's, so the translation happens here. +# takes "osx" and "xros", the Connect API calls the same things MAC_OS and +# VISION_OS. The lanes speak deliver's, so the translation happens here. Note +# visionOS is spelled a third way again in appstore/screenshots — see the +# visionos platform above. CONNECT_PLATFORM = { "ios" => Spaceship::ConnectAPI::Platform::IOS, - "osx" => Spaceship::ConnectAPI::Platform::MAC_OS + "osx" => Spaceship::ConnectAPI::Platform::MAC_OS, + "xros" => Spaceship::ConnectAPI::Platform::VISION_OS }.freeze private_lane :diff_metadata do |options| diff --git a/fastlane/metadata_check.rb b/fastlane/metadata_check.rb index 31dfe59..8978069 100755 --- a/fastlane/metadata_check.rb +++ b/fastlane/metadata_check.rb @@ -28,10 +28,13 @@ module MetadataCheck REQUIRED = (LIMITS.keys + URLS).sort.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. + # 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 + # directory has to be added here to be seen. SIZES = { "ios" => [[2064, 2752], [2752, 2064]], # iPad 13-inch - "macos" => [[1280, 800], [1440, 900], [2560, 1600], [2880, 1800]] + "macos" => [[1280, 800], [1440, 900], [2560, 1600], [2880, 1800]], + "visionos" => [[3840, 2160]] # Apple Vision Pro }.freeze DEFAULT_ROOT = Pathname.new(__dir__).parent / "appstore" diff --git a/site/index.html b/site/index.html index a8b9b2f..2103d83 100644 --- a/site/index.html +++ b/site/index.html @@ -4,14 +4,14 @@ Tortoise Blocks — Snap blocks together, watch the tortoise draw - + - + @@ -245,7 +245,8 @@

Snap blocks together, and watch the tortoise draw.

- A visual programming app for kids on iPad and Mac. Drag blocks into a + A visual programming app for kids on iPad, Mac and Apple Vision Pro. Drag + blocks into a program, press ▶️, and a tortoise draws your picture line by line.

@@ -254,7 +255,7 @@

Snap blocks together, and watch the tortoise draw.

Download on the App Store -

iPadOS 26 or later · macOS 26 or later

+

iPadOS 26 or later · macOS 26 or later · visionOS 26 or later

@@ -390,9 +391,9 @@

From blocks to text

-

iPad and Mac, one app

+

iPad, Mac and Vision Pro, one app

- The same three panes on both, at home with each. It is an ordinary + 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.

@@ -422,7 +423,7 @@

Quiet about the child using it

Requires
-
iPadOS 26 or later · macOS 26 or later
+
iPadOS 26 or later · macOS 26 or later · visionOS 26 or later
Languages
@@ -449,14 +450,14 @@

Quiet about the child using it

ブロックをならべて、カメに絵をかいてもらおう!

-

iPad と Mac のための、子ども向けビジュアルプログラミングアプリ。ブロックを並べて ▶️ を押すと、カメが1本ずつ線を描いていきます。

+

iPad と Mac と Apple Vision Pro のための、子ども向けビジュアルプログラミングアプリ。ブロックを並べて ▶️ を押すと、カメが1本ずつ線を描いていきます。

App Store でダウンロード -

iPadOS 26 以降 · macOS 26 以降

+

iPadOS 26 以降 · macOS 26 以降 · visionOS 26 以降

@@ -553,8 +554,8 @@

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

-

iPad でも Mac でも、同じアプリ

-

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

+

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

+

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

使う子どものことを、何も集めません
動作環境
-
iPadOS 26 以降 · macOS 26 以降
+
iPadOS 26 以降 · macOS 26 以降 · visionOS 26 以降
対応言語