Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
1459028
Build and run on visionOS
temoki Aug 12, 2026
9f49526
Open the workspace column at 440pt
temoki Aug 12, 2026
670be04
Give visionOS an app icon
temoki Aug 12, 2026
d69283a
Merge pull request #51 from temoki/feature/visionos
temoki Aug 12, 2026
60a78c7
Polish the three things visionOS got wrong
temoki Aug 12, 2026
2e99c9e
Let the thumbnail extension sign for a visionOS device
temoki Aug 12, 2026
e000dee
Give visionOS its hover effect back
temoki Aug 12, 2026
cdbe446
Put visionOS in the store listing and the site
temoki Aug 12, 2026
84952f1
Put a drawing on the table, to find out what that costs (#53 Phase 0)
temoki Aug 13, 2026
c02fb69
Declare world sensing, so the device run is not wasted (#53 Phase 0)
temoki Aug 13, 2026
a18818b
Let the wearer size and spin the sheet (#53 Phase 0)
temoki Aug 13, 2026
9819ba2
Turn the sheet with a wrist, and slide it with a drag (#53 Phase 0)
temoki Aug 13, 2026
ca04644
Keep the sheet on the table, and stop stretching the render (#53 Phas…
temoki Aug 13, 2026
e03e864
Give the sheet 2m back, and stop paying for a render that buys nothin…
temoki Aug 13, 2026
a8c976d
Say what builtSide actually is, and walk back an overclaim (#53 Phase 0)
temoki Aug 13, 2026
160b335
Make visionOS a viewer instead of the editor (#53 Phase 1)
temoki Aug 13, 2026
7fcf4a4
Let the viewer window be the size of its controls (#53 Phase 1)
temoki Aug 15, 2026
b856bf0
Show the program on its own surface, following the playback (#53 Phas…
temoki Aug 15, 2026
7a2f77d
Follow the playhead, and stop the row menu tinting its own popup (#53…
temoki Aug 15, 2026
c7c2f75
Let the program window scroll, and keep the ⋯ ink off its own popup (…
temoki Aug 15, 2026
0ed4d91
Drop the way back to a browser that is no longer there (#53 Phase 2)
temoki Aug 15, 2026
a2cfbe8
Put the drawing where you are looking, and say so while it looks (#53)
temoki Aug 15, 2026
c238096
Stop choosing the floor to draw on (#53)
temoki Aug 15, 2026
2d371b5
Add the 3D tortoise, generated from the three-view drawing (#53)
temoki Aug 16, 2026
c5b4388
Stand the tortoise on the table (#53 Phase 3)
temoki Aug 17, 2026
4ef3812
Give the viewer a code window, and a way out (#53 Phase 3)
temoki Aug 17, 2026
4400369
Stop the room draining the tortoise's colours (#53 Phase 3)
temoki Aug 17, 2026
8bfac99
Say what the headset actually senses (#53)
temoki Aug 17, 2026
8241363
Read the debug flag without touching UserDefaults (#53)
temoki Aug 17, 2026
3e917f6
Take SVG/PNG export back out of the viewer (#53)
temoki Aug 17, 2026
abe7653
Stop -TBPlace promising a picture it cannot show (#53)
temoki Aug 17, 2026
8f374f9
Group the viewer's controls by what they do (#53)
temoki Aug 18, 2026
2f9fe4c
Record that the ornament was considered and declined (#53)
temoki Aug 18, 2026
f2338ca
Open a drawing, and it goes on the table (#53)
temoki Aug 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 34 additions & 3 deletions .claude/skills/release/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,18 @@ The policy page states what was *measured* — no accounts, no analytics, no
advertising or third-party SDK, no tracking, and no networking code anywhere
in the app or in TortoiseGraphics2 — which is also why no
`PrivacyInfo.xcprivacy` is needed: nothing here touches a required-reason
API, not even `UserDefaults`. Re-check that if a dependency or an
`@AppStorage` ever arrives. **Both pages read in one language**, chosen by
API, not even `UserDefaults`. That last one is **held on purpose and has
already had to be defended once**: #53's viewer read a debug launch flag with
`UserDefaults.standard.bool(forKey:)`, which is where a `-flag value` pair
normally arrives, and one debug read of it obliges the whole app to ship a
manifest declaring `CA92.1`. It now reads `ProcessInfo.processInfo.arguments`
instead — same launch command, no manifest. So the thing to re-check is not
only "a dependency or an `@AppStorage`": *any* `UserDefaults` call counts,
including a read, including one that only fires in development, because it is
compiled into the shipping binary. `NSWorldSensingUsageDescription` (#53) is
**not** on that list — a usage description is a permission, not a
required-reason API, and it changes the policy page rather than the manifest.
**Both pages read in one language**, chosen by
`?lang=` first, then `navigator.languages` in the reader's own order, falling
back to English; adding a language is a code in `LANGS`, an `<option>`, and a
translated `<section>`. Two rules hold it together: the site stores nothing (a
Expand Down Expand Up @@ -126,8 +136,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 <device> 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
Expand Down
11 changes: 6 additions & 5 deletions .github/workflows/appstore-metadata.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
11 changes: 10 additions & 1 deletion App/Document/BlocksDocument.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,23 @@ struct BlocksDocument: FileDocument {
guard let data = configuration.file.regularFileContents else {
throw CocoaError(.fileReadCorruptFile)
}
self.project = try Self.project(from: data)
}

/// Decoding, apart from `FileDocument`, because the visionOS viewer (#53)
/// reads `.tortoise` files through a `fileImporter` and has no
/// `DocumentGroup` to hand it a `ReadConfiguration`. The version gate has
/// to be the same one, or the two ways in would disagree about which files
/// are from the future.
static func project(from data: Data) throws -> BlocksProject {
// Probe just the version before the full decode: a newer file's
// unknown block shapes would fail the full decode with a generic
// "corrupt" error before the version gate could explain it.
let probe = try JSONDecoder().decode(SchemaVersionProbe.self, from: data)
guard probe.schemaVersion <= BlocksProject.currentSchemaVersion else {
throw DocumentError.newerSchema
}
self.project = try JSONDecoder().decode(BlocksProject.self, from: data)
return try JSONDecoder().decode(BlocksProject.self, from: data)
}

func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
Expand Down
12 changes: 12 additions & 0 deletions App/InfoPlist.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,18 @@
}
}
},
"NSWorldSensingUsageDescription" : {
"comment" : "Privacy - World Sensing Usage Description",
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "Used to place your drawing on a real table."
}
}
}
},
"Tortoise Blocks Block" : {

},
Expand Down
130 changes: 130 additions & 0 deletions App/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,56 @@
}
}
},
"Away" : {
"localizations" : {
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "ださない"
}
}
}
},
"Blocks" : {
"localizations" : {
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "ブロック"
}
}
}
},
"In Front of You" : {
"localizations" : {
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "めのまえ"
}
}
}
},
"On a Table" : {
"localizations" : {
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "つくえ"
}
}
}
},
"Where should the drawing go?" : {
"localizations" : {
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "えを どこに だす?"
}
}
}
},
"🎲" : {
"localizations" : {
"ja" : {
Expand Down Expand Up @@ -184,6 +234,16 @@
}
}
},
"Can't Open This" : {
"localizations" : {
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "これは ひらけません"
}
}
}
},
"Cancel" : {
"localizations" : {
"ja" : {
Expand Down Expand Up @@ -494,6 +554,16 @@
}
}
},
"Looking for a table…" : {
"localizations" : {
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "つくえを さがしています…"
}
}
}
},
"item %lld" : {
"localizations" : {
"ja" : {
Expand Down Expand Up @@ -634,6 +704,26 @@
}
}
},
"No Drawing" : {
"localizations" : {
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "えが ありません"
}
}
}
},
"No table found, so it is floating in front of you." : {
"localizations" : {
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "つくえが みつからないので、めのまえに だしているよ。"
}
}
}
},
"Nothing to play" : {
"localizations" : {
"ja" : {
Expand Down Expand Up @@ -674,6 +764,26 @@
}
}
},
"Open" : {
"localizations" : {
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "ひらく"
}
}
}
},
"Open a drawing, or start from a sample." : {
"localizations" : {
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "えを ひらくか、サンプルから はじめてね。"
}
}
}
},
"or less" : {
"localizations" : {
"ja" : {
Expand Down Expand Up @@ -784,6 +894,16 @@
}
}
},
"Pinch to resize, twist to turn, drag to move." : {
"localizations" : {
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "つまむと おおきさ、ひねると むき、うごかすと ばしょが かわるよ。"
}
}
}
},
"Play" : {
"localizations" : {
"ja" : {
Expand Down Expand Up @@ -884,6 +1004,16 @@
}
}
},
"Reset Position" : {
"localizations" : {
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "もとの ばしょに もどす"
}
}
}
},
"Resume" : {
"localizations" : {
"ja" : {
Expand Down
28 changes: 21 additions & 7 deletions App/Models/RunnerModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,16 @@ final class RunnerModel {
/// exports render exactly what is on screen.
private(set) var lastRunCommands: [TortoiseCommand] = []

/// Bounding box of the last run's visible output, or nil when it drew
/// nothing. `nil` on an empty stream, exactly as `DrawingBounds` reports it.
///
/// Computed once per run rather than per use, because both users want it
/// repeatedly and neither can afford the replay: the export sizes its
/// frame from it (once per export, previously replaying the stream again),
/// and the visionOS table maps the 3-D tortoise's position through the
/// same `autoFit` transform the canvas uses — *every display frame*.
private(set) var drawingBounds: DrawingBounds?

/// Set when expansion fails; drives a kid-friendly alert.
var showsExpansionError = false

Expand Down Expand Up @@ -114,6 +124,8 @@ final class RunnerModel {
let expanded = try BlockExpander.expand(blocks)
expandedBlockIDs = expanded.map(\.blockID)
lastRunCommands = expanded.map(\.command)
drawingBounds = DrawingBounds.compute(
from: CommandPlayer.play(commands: lastRunCommands))
lastRunTreeHash = blocks.hashValue
player.isPaused = startPaused
tortoise.reset()
Expand Down Expand Up @@ -182,7 +194,8 @@ final class RunnerModel {
func pngData(scale: CGFloat = 2) -> Data? {
if let cached = pngDataCache[scale] { return cached }
guard canExport else { return nil }
let data = Self.renderPNG(lastRunCommands, longSide: 512, scale: scale)
let data = Self.renderPNG(
lastRunCommands, bounds: drawingBounds, longSide: 512, scale: scale)
pngDataCache[scale] = data
return data
}
Expand All @@ -209,7 +222,8 @@ final class RunnerModel {
/// putting the picture on the file in the first place.
func thumbnailData() -> Data? {
guard canExport else { return nil }
return Self.renderPNG(lastRunCommands, longSide: 256, scale: 1, onWhite: true)
return Self.renderPNG(
lastRunCommands, bounds: drawingBounds, longSide: 256, scale: 1, onWhite: true)
}

/// Renders a command stream to PNG, cropped tight to the drawing and
Expand All @@ -228,14 +242,15 @@ final class RunnerModel {
/// exports transparent, because an export is a picture you place somewhere
/// yourself, and the thumbnail white, because Finder places that one.
private static func renderPNG(
_ commands: [TortoiseCommand], longSide: Double, scale: CGFloat, onWhite: Bool = false
_ commands: [TortoiseCommand], bounds: DrawingBounds?, longSide: Double, scale: CGFloat,
onWhite: Bool = false
) -> Data? {
let export = Tortoise()
export.speed = 0
export.backgroundColor = onWhite ? .white : .clear
export.apply(commands)
export.hideTortoise()
let size = exportFrameSize(for: commands, longSide: longSide)
let size = exportFrameSize(for: bounds, longSide: longSide)
let renderer = ImageRenderer(
content: TortoiseCanvas(export)
.frame(width: size.width, height: size.height)
Expand All @@ -255,11 +270,10 @@ final class RunnerModel {
/// an unusable sliver; an empty drawing (no bounds) falls back to a square,
/// mirroring SVG's own "no visible output" fallback.
private static func exportFrameSize(
for commands: [TortoiseCommand], longSide long: Double
for bounds: DrawingBounds?, longSide long: Double
) -> CGSize {
let square = CGSize(width: long, height: long)
guard let bounds = DrawingBounds.compute(from: CommandPlayer.play(commands: commands))
else { return square }
guard let bounds else { return square }
// Mirrors TortoiseUI's autoFit inset — the tortoise sprite's
// half-extent × tortoiseScaleMax — so the drawing fills the frame with
// a uniform margin instead of a lopsided one. 20 is the *triangle's*
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading