bakeshot 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. bakeshot-0.1.0/LICENSE +21 -0
  2. bakeshot-0.1.0/PKG-INFO +165 -0
  3. bakeshot-0.1.0/README.md +149 -0
  4. bakeshot-0.1.0/bakeshot/__init__.py +1 -0
  5. bakeshot-0.1.0/bakeshot/bake.py +293 -0
  6. bakeshot-0.1.0/bakeshot/cli.py +165 -0
  7. bakeshot-0.1.0/bakeshot/devices.py +25 -0
  8. bakeshot-0.1.0/bakeshot/doctor.py +55 -0
  9. bakeshot-0.1.0/bakeshot/features.py +32 -0
  10. bakeshot-0.1.0/bakeshot/hostkit/BakeshotSceneAPI.swift.template +64 -0
  11. bakeshot-0.1.0/bakeshot/hostkit/RenderTests.swift.template +112 -0
  12. bakeshot-0.1.0/bakeshot/hostkit/add_test_target.rb +140 -0
  13. bakeshot-0.1.0/bakeshot/hostkit/agent_guide.md +73 -0
  14. bakeshot-0.1.0/bakeshot/hostkit/run_in_xcode.applescript +40 -0
  15. bakeshot-0.1.0/bakeshot/hostkit/skill/SKILL.md +116 -0
  16. bakeshot-0.1.0/bakeshot/hostkit/start_test.applescript +46 -0
  17. bakeshot-0.1.0/bakeshot/hostkit/stop_test.applescript +26 -0
  18. bakeshot-0.1.0/bakeshot/license.py +66 -0
  19. bakeshot-0.1.0/bakeshot/scenes.py +106 -0
  20. bakeshot-0.1.0/bakeshot/usage.py +94 -0
  21. bakeshot-0.1.0/bakeshot/xcode.py +60 -0
  22. bakeshot-0.1.0/bakeshot.egg-info/PKG-INFO +165 -0
  23. bakeshot-0.1.0/bakeshot.egg-info/SOURCES.txt +32 -0
  24. bakeshot-0.1.0/bakeshot.egg-info/dependency_links.txt +1 -0
  25. bakeshot-0.1.0/bakeshot.egg-info/entry_points.txt +2 -0
  26. bakeshot-0.1.0/bakeshot.egg-info/top_level.txt +1 -0
  27. bakeshot-0.1.0/pyproject.toml +32 -0
  28. bakeshot-0.1.0/setup.cfg +4 -0
  29. bakeshot-0.1.0/tests/test_config.py +43 -0
  30. bakeshot-0.1.0/tests/test_devices.py +20 -0
  31. bakeshot-0.1.0/tests/test_prune.py +50 -0
  32. bakeshot-0.1.0/tests/test_scenes.py +68 -0
  33. bakeshot-0.1.0/tests/test_skip_names.py +58 -0
  34. bakeshot-0.1.0/tests/test_usage.py +62 -0
bakeshot-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ryohei Horiuchi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,165 @@
1
+ Metadata-Version: 2.4
2
+ Name: bakeshot
3
+ Version: 0.1.0
4
+ Summary: Capture the same UI your users see, without a simulator or a device.
5
+ License: MIT
6
+ Project-URL: Homepage, https://github.com/Ryoheihoriuchi0000/bakeshot
7
+ Project-URL: Issues, https://github.com/Ryoheihoriuchi0000/bakeshot/issues
8
+ Keywords: ios,screenshots,app-store,fastlane,snapshot,swiftui,xcode
9
+ Classifier: Environment :: MacOS X
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Topic :: Software Development :: Build Tools
12
+ Requires-Python: >=3.9
13
+ Description-Content-Type: text/markdown
14
+ License-File: LICENSE
15
+ Dynamic: license-file
16
+
17
+ # Bakeshot
18
+
19
+ **Capture the same UI your users see — without a simulator or a device.**
20
+
21
+ https://github.com/user-attachments/assets/6a902e87-bb5d-467f-8c55-96d89f550ac3
22
+
23
+ ## Without Bakeshot
24
+
25
+ Getting the screens you want means installing the app, opening the right
26
+ screen, getting it into the right state, placing the widget, configuring it, getting real
27
+ data into it — and only then taking the shot.
28
+
29
+ | The screenshot you want | What you or your agent has to do first |
30
+ |---|---|
31
+ | A home screen with a month of history | Seed the database, or tap the data in by hand |
32
+ | A seven-day streak | Use the app for seven days running — or forge the dates in storage |
33
+ | The paid tier, active | Set up a StoreKit sandbox account and buy through it |
34
+ | An empty state, an error state | Wipe the data, or cut the network, and hope nothing else changes |
35
+ | A chart that looks good | Keep re-seeding until the numbers happen to make a nice shape |
36
+ | The same shots in five languages | Change the device language and take every shot again, five times |
37
+ | A widget | Install the app, add the widget, configure it, get data into it, then photograph the home screen and crop |
38
+
39
+ And you do all of it again next release.
40
+
41
+ ## With Bakeshot
42
+
43
+ You describe the state in code; Bakeshot builds your app, runs it on your Mac as a real iOS
44
+ binary, and draws the view.
45
+
46
+ ```swift
47
+ shotBoth("home") { HomeView().environmentObject(thirtyDaysOfHistory()) }
48
+ shot("paywall", dark: true) { PlusSheet().environmentObject(PlusStore(active: true)) }
49
+ ```
50
+
51
+ Nothing is simulated or mocked up: these are your real views, your real fonts and assets,
52
+ rendered by iOS. The app never has to reach the state — the state is handed to it.
53
+
54
+ It does not decorate. Backgrounds, captions and device frames are somebody else's job
55
+ (try [app-store-screenshots](https://github.com/ParthJadhav/app-store-screenshots)).
56
+ Bakeshot does the part nobody has automated: **the real UI, in the state that sells it.**
57
+
58
+ ## Use it
59
+
60
+ ```bash
61
+ pip install git+https://github.com/Ryoheihoriuchi0000/bakeshot
62
+ cd MyApp
63
+ bakeshot init
64
+ ```
65
+
66
+ Then tell your coding agent:
67
+
68
+ > **Bakeshot を使って App Store Connect 用のプロモーション画像を作成して**
69
+ > — or, in English, *"use Bakeshot to create the promotional images for App Store Connect"*
70
+
71
+ `init` installs a skill into `.claude/skills/bakeshot/`, so the agent reads your models and
72
+ views, writes the scene script, runs the render, fixes whatever crashed, and tells you where
73
+ the images are. You do not have to learn the flags, or the file, or any of this README.
74
+
75
+ <details>
76
+ <summary>Not using Claude Code?</summary>
77
+
78
+ Point any agent at the skill: *"Read `.claude/skills/bakeshot/SKILL.md` and follow it."*
79
+ Or drive it yourself: edit `Bakeshot/Scenes.swift`, then `bakeshot bake --locale ja --locale en`.
80
+
81
+ </details>
82
+
83
+ PNGs land in `Bakeshot/out/<locale>/`, at real store dimensions.
84
+
85
+ ## The scene script
86
+
87
+ `Bakeshot/Scenes.swift` is where you say what to shoot and in what state. It is plain
88
+ Swift against your own types, which is why an agent can write it:
89
+
90
+ ```swift
91
+ enum BakeshotScenes {
92
+ @MainActor static var scenes: [BakeshotScene] {
93
+ [
94
+ shotBoth("home_busy") { HomeView().environmentObject(busyStore()) },
95
+ shot("paywall", dark: true) { PlusSheet().environmentObject(PlusStore()) },
96
+ shotBoth("widget", size: .widgetMedium) { MyWidgetView(entry: demoEntry()) },
97
+ ].flatMap { $0 }
98
+ }
99
+ }
100
+
101
+ @MainActor private func busyStore() -> AppStore {
102
+ let s = AppStore()
103
+ s.tags = [Tag(name: "Study"), Tag(name: "Workout"), Tag(name: "Work")]
104
+ s.logs = (0..<20).map { LogEntry(minutes: 30 + $0 * 5) }
105
+ return s
106
+ }
107
+ ```
108
+
109
+ An empty app makes a worthless screenshot. The state is the product.
110
+
111
+ ## Does it touch my project?
112
+
113
+ **No.** Bakeshot never edits your files, your `.xcodeproj`, or your git history.
114
+
115
+ To render your views it needs one test target hosted by your app. Rather than add that
116
+ to your project, it works on a hidden throwaway copy of the `.xcodeproj`, and deletes it
117
+ when the run ends — including when the run fails. Nothing is left behind.
118
+
119
+ <details>
120
+ <summary>What actually happens</summary>
121
+
122
+ 1. Copy `MyApp.xcodeproj` → `.MyApp-bakeshot.xcodeproj` (hidden, temporary, sits next to
123
+ the original because Xcode projects reference their files by relative path).
124
+ 2. Add a test target to the copy, hosted by your app.
125
+ 3. Build the copy for *My Mac (Designed for iPad)* — your iOS binary, running on macOS,
126
+ so Firebase, UIKit, your fonts and assets are all the real thing.
127
+ 4. Render each scene with `UIHostingController` + `drawHierarchy`, write PNGs.
128
+ 5. Delete the copy.
129
+
130
+ </details>
131
+
132
+ ## Requirements
133
+
134
+ - Apple silicon Mac with Xcode
135
+ - `gem install --user-install xcodeproj`
136
+ - Your app must be signable with your own team — the setup you already build with
137
+
138
+ `bakeshot init` checks all of this and tells you what is missing.
139
+
140
+ ## Price
141
+
142
+ Everything above is free: every screen, every state, every language, every device size.
143
+
144
+ | | |
145
+ |---|---|
146
+ | Public | free, forever — app screens |
147
+ | Widgets | **$49 / year** |
148
+ | Team / agency | **$199 / year** — several projects, CI |
149
+ | Launch offer | **$99 once**, first 100 buyers |
150
+
151
+ The version you paid for keeps working forever; renewing buys the next year of updates.
152
+ Xcode breaks this kind of tool roughly once a year — that is what the renewal pays for.
153
+
154
+ ## Known limits
155
+
156
+ - Xcode is driven while baking, so you cannot use Xcode during a run.
157
+ (`xcodebuild test` cannot launch an iOS-on-Mac test host; the IDE can.)
158
+ - Screens that need network, keychain or a live account crash while rendering.
159
+ Bakeshot names them and bakes the rest — give them fakes in the scene script.
160
+ - Widget sources are compiled into the test bundle, so a widget that depends on the
161
+ extension's own asset catalog may render without those images.
162
+
163
+ ## License
164
+
165
+ MIT
@@ -0,0 +1,149 @@
1
+ # Bakeshot
2
+
3
+ **Capture the same UI your users see — without a simulator or a device.**
4
+
5
+ https://github.com/user-attachments/assets/6a902e87-bb5d-467f-8c55-96d89f550ac3
6
+
7
+ ## Without Bakeshot
8
+
9
+ Getting the screens you want means installing the app, opening the right
10
+ screen, getting it into the right state, placing the widget, configuring it, getting real
11
+ data into it — and only then taking the shot.
12
+
13
+ | The screenshot you want | What you or your agent has to do first |
14
+ |---|---|
15
+ | A home screen with a month of history | Seed the database, or tap the data in by hand |
16
+ | A seven-day streak | Use the app for seven days running — or forge the dates in storage |
17
+ | The paid tier, active | Set up a StoreKit sandbox account and buy through it |
18
+ | An empty state, an error state | Wipe the data, or cut the network, and hope nothing else changes |
19
+ | A chart that looks good | Keep re-seeding until the numbers happen to make a nice shape |
20
+ | The same shots in five languages | Change the device language and take every shot again, five times |
21
+ | A widget | Install the app, add the widget, configure it, get data into it, then photograph the home screen and crop |
22
+
23
+ And you do all of it again next release.
24
+
25
+ ## With Bakeshot
26
+
27
+ You describe the state in code; Bakeshot builds your app, runs it on your Mac as a real iOS
28
+ binary, and draws the view.
29
+
30
+ ```swift
31
+ shotBoth("home") { HomeView().environmentObject(thirtyDaysOfHistory()) }
32
+ shot("paywall", dark: true) { PlusSheet().environmentObject(PlusStore(active: true)) }
33
+ ```
34
+
35
+ Nothing is simulated or mocked up: these are your real views, your real fonts and assets,
36
+ rendered by iOS. The app never has to reach the state — the state is handed to it.
37
+
38
+ It does not decorate. Backgrounds, captions and device frames are somebody else's job
39
+ (try [app-store-screenshots](https://github.com/ParthJadhav/app-store-screenshots)).
40
+ Bakeshot does the part nobody has automated: **the real UI, in the state that sells it.**
41
+
42
+ ## Use it
43
+
44
+ ```bash
45
+ pip install git+https://github.com/Ryoheihoriuchi0000/bakeshot
46
+ cd MyApp
47
+ bakeshot init
48
+ ```
49
+
50
+ Then tell your coding agent:
51
+
52
+ > **Bakeshot を使って App Store Connect 用のプロモーション画像を作成して**
53
+ > — or, in English, *"use Bakeshot to create the promotional images for App Store Connect"*
54
+
55
+ `init` installs a skill into `.claude/skills/bakeshot/`, so the agent reads your models and
56
+ views, writes the scene script, runs the render, fixes whatever crashed, and tells you where
57
+ the images are. You do not have to learn the flags, or the file, or any of this README.
58
+
59
+ <details>
60
+ <summary>Not using Claude Code?</summary>
61
+
62
+ Point any agent at the skill: *"Read `.claude/skills/bakeshot/SKILL.md` and follow it."*
63
+ Or drive it yourself: edit `Bakeshot/Scenes.swift`, then `bakeshot bake --locale ja --locale en`.
64
+
65
+ </details>
66
+
67
+ PNGs land in `Bakeshot/out/<locale>/`, at real store dimensions.
68
+
69
+ ## The scene script
70
+
71
+ `Bakeshot/Scenes.swift` is where you say what to shoot and in what state. It is plain
72
+ Swift against your own types, which is why an agent can write it:
73
+
74
+ ```swift
75
+ enum BakeshotScenes {
76
+ @MainActor static var scenes: [BakeshotScene] {
77
+ [
78
+ shotBoth("home_busy") { HomeView().environmentObject(busyStore()) },
79
+ shot("paywall", dark: true) { PlusSheet().environmentObject(PlusStore()) },
80
+ shotBoth("widget", size: .widgetMedium) { MyWidgetView(entry: demoEntry()) },
81
+ ].flatMap { $0 }
82
+ }
83
+ }
84
+
85
+ @MainActor private func busyStore() -> AppStore {
86
+ let s = AppStore()
87
+ s.tags = [Tag(name: "Study"), Tag(name: "Workout"), Tag(name: "Work")]
88
+ s.logs = (0..<20).map { LogEntry(minutes: 30 + $0 * 5) }
89
+ return s
90
+ }
91
+ ```
92
+
93
+ An empty app makes a worthless screenshot. The state is the product.
94
+
95
+ ## Does it touch my project?
96
+
97
+ **No.** Bakeshot never edits your files, your `.xcodeproj`, or your git history.
98
+
99
+ To render your views it needs one test target hosted by your app. Rather than add that
100
+ to your project, it works on a hidden throwaway copy of the `.xcodeproj`, and deletes it
101
+ when the run ends — including when the run fails. Nothing is left behind.
102
+
103
+ <details>
104
+ <summary>What actually happens</summary>
105
+
106
+ 1. Copy `MyApp.xcodeproj` → `.MyApp-bakeshot.xcodeproj` (hidden, temporary, sits next to
107
+ the original because Xcode projects reference their files by relative path).
108
+ 2. Add a test target to the copy, hosted by your app.
109
+ 3. Build the copy for *My Mac (Designed for iPad)* — your iOS binary, running on macOS,
110
+ so Firebase, UIKit, your fonts and assets are all the real thing.
111
+ 4. Render each scene with `UIHostingController` + `drawHierarchy`, write PNGs.
112
+ 5. Delete the copy.
113
+
114
+ </details>
115
+
116
+ ## Requirements
117
+
118
+ - Apple silicon Mac with Xcode
119
+ - `gem install --user-install xcodeproj`
120
+ - Your app must be signable with your own team — the setup you already build with
121
+
122
+ `bakeshot init` checks all of this and tells you what is missing.
123
+
124
+ ## Price
125
+
126
+ Everything above is free: every screen, every state, every language, every device size.
127
+
128
+ | | |
129
+ |---|---|
130
+ | Public | free, forever — app screens |
131
+ | Widgets | **$49 / year** |
132
+ | Team / agency | **$199 / year** — several projects, CI |
133
+ | Launch offer | **$99 once**, first 100 buyers |
134
+
135
+ The version you paid for keeps working forever; renewing buys the next year of updates.
136
+ Xcode breaks this kind of tool roughly once a year — that is what the renewal pays for.
137
+
138
+ ## Known limits
139
+
140
+ - Xcode is driven while baking, so you cannot use Xcode during a run.
141
+ (`xcodebuild test` cannot launch an iOS-on-Mac test host; the IDE can.)
142
+ - Screens that need network, keychain or a live account crash while rendering.
143
+ Bakeshot names them and bakes the rest — give them fakes in the scene script.
144
+ - Widget sources are compiled into the test bundle, so a widget that depends on the
145
+ extension's own asset catalog may render without those images.
146
+
147
+ ## License
148
+
149
+ MIT
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,293 @@
1
+ """焼く本体。本物の iOS ランタイム(My Mac / Designed for iPad)でアプリの View を描く。
2
+
3
+ ⚠ `xcodebuild test` はホストの起動で死ぬ(Xcode 26.3、最小アプリでも再現)。
4
+ 同じ xcodeproj を Xcode IDE が走らせると通るので、AppleScript で Xcode を操作する。
5
+ """
6
+ import re
7
+ import shutil
8
+ import time
9
+ import uuid
10
+ from pathlib import Path
11
+
12
+ from . import devices, features, scenes as scenes_mod, xcode
13
+
14
+ KIT = Path(__file__).parent / "hostkit"
15
+ CONTAINERS = Path.home() / "Library" / "Containers"
16
+
17
+
18
+ def log(msg, kind="·"):
19
+ print(f"{kind} {msg}", flush=True)
20
+
21
+
22
+ _used_widgets = False
23
+
24
+
25
+ def used_widgets() -> bool:
26
+ return _used_widgets
27
+
28
+
29
+ def error_lines(out: str):
30
+ """ビルド出力からコンパイルエラーの行だけ。"""
31
+ return [l for l in out.splitlines() if ": error:" in l]
32
+
33
+
34
+ def failing_shot_lines(errs, lines):
35
+ """エラーの出た行のうち、**撮影行だけ**を返す。
36
+
37
+ 撮影行以外を消すと土台のコードが壊れ、無関係な構文エラーだらけになって
38
+ 本当の原因が見えなくなる。実際にそうなっていたので、ここは厳しくする。
39
+ 戻り値は [(行番号1始まり, 画面の名前)]。"""
40
+ nums = {int(m.group(1)) for l in errs
41
+ for m in [re.search(r"RenderTests\.swift:(\d+):", l)] if m}
42
+ found = []
43
+ for n in sorted(nums, reverse=True):
44
+ if n - 1 >= len(lines):
45
+ continue
46
+ m = re.search(r'bake\("([^"]+?)(?:_light|_dark)?"', lines[n - 1])
47
+ if m:
48
+ found.append((n, m.group(1)))
49
+ return found
50
+
51
+
52
+ def skip_names(names):
53
+ """撮影側は _light / _dark を落とした名前で照合する。合わせないと素通りする。"""
54
+ return sorted({re.sub(r"_(light|dark)$", "", n) for n in names})
55
+
56
+
57
+ # fatalError などのメッセージは stderr に出てからプロセスが死ぬ。描画側がそれをファイルに残す
58
+ _NOISE = ("<unknown>", "CoreSimulator", "nw_", "objc[", "Metal", "AVF ", "Could not create")
59
+
60
+
61
+ def crash_reason(box) -> str:
62
+ """落ちた理由を1〜2行で。何も無ければ空。"""
63
+ if not box:
64
+ return ""
65
+ f = box / "STDERR"
66
+ if not f.exists():
67
+ return ""
68
+ try:
69
+ text = f.read_text(encoding="utf-8", errors="replace")
70
+ except OSError:
71
+ return ""
72
+ lines = [l.strip() for l in text.splitlines() if l.strip()]
73
+ keep = [l for l in lines if not any(n in l for n in _NOISE)]
74
+ fatal = [i for i, l in enumerate(keep) if "Fatal error" in l or "Precondition failed" in l]
75
+ if fatal:
76
+ i = fatal[-1]
77
+ return " ".join(keep[i:i + 2])[:300]
78
+ return keep[-1][:300] if keep else ""
79
+
80
+
81
+ def find_out_dir(token: str):
82
+ """アプリのサンドボックスにある受け皿。コンテナは走るまで決まらない。"""
83
+ for c in CONTAINERS.iterdir() if CONTAINERS.is_dir() else []:
84
+ d = c / "Data" / "tmp" / "bakeshot_out" / token
85
+ if d.is_dir():
86
+ return d
87
+ return None
88
+
89
+
90
+ def ensure_scenes(root: Path, module: str, imports: str) -> Path:
91
+ """台本を用意する。**何をどんな状態で撮るかは利用者(とそのエージェント)が書く。**
92
+ 自動検出は下書きでしかない(空の初期状態しか出せない)。"""
93
+ d = root / "Bakeshot"
94
+ f = d / "Scenes.swift"
95
+ if f.exists():
96
+ return f
97
+ d.mkdir(parents=True, exist_ok=True)
98
+ views = scenes_mod.guess_views(root)
99
+ f.write_text(scenes_mod.draft(module, imports, views), encoding="utf-8")
100
+ # エージェント用のスキル。置いておけば、利用者は「スクショ作って」で済む
101
+ skill_src = KIT / "skill" / "SKILL.md"
102
+ skill_dst = root / ".claude" / "skills" / "bakeshot" / "SKILL.md"
103
+ if skill_src.exists() and not skill_dst.exists():
104
+ skill_dst.parent.mkdir(parents=True, exist_ok=True)
105
+ skill_dst.write_text(skill_src.read_text(encoding="utf-8"), encoding="utf-8")
106
+ for src_name, dst_name in [("agent_guide.md", "AGENT.md")]:
107
+ g = KIT / src_name
108
+ if g.exists():
109
+ (d / dst_name).write_text(g.read_text(encoding="utf-8").replace("__MODULE__", module),
110
+ encoding="utf-8")
111
+ return f
112
+
113
+
114
+ def bake(root: Path, project: Path, app_target: str, locale: str, device: str,
115
+ out_root: Path, bundle_id: str = "", strip_ext: bool = True,
116
+ strip_ent: bool = False, team: str = "", keep: bool = False):
117
+ features.pro() # 完全版が入っていれば端末とウィジェットが増える
118
+ module = scenes_mod.module_name(app_target)
119
+ # Swift の識別子にならない名前(protoc-gen-swift のようなハイフン入り)は import できない
120
+ _ok = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
121
+ imports = "".join(f"#if canImport({m})\nimport {m}\n#endif\n"
122
+ for m in sorted(scenes_mod.local_package_modules(root)) if _ok.match(m))
123
+ scenes_file = ensure_scenes(root, module, imports)
124
+ names = scenes_mod.names(scenes_file.read_text(encoding="utf-8"))
125
+ if not names:
126
+ raise SystemExit("台本に絵がありません。Bakeshot/Scenes.swift に shot / shotBoth を書いてください。")
127
+
128
+ global _used_widgets
129
+ token = uuid.uuid4().hex[:8]
130
+ work = root / "Bakeshot" / ".work"
131
+ shutil.rmtree(work, ignore_errors=True)
132
+ work.mkdir(parents=True, exist_ok=True)
133
+
134
+ w, h, top, bottom, _px = devices.metrics(device)
135
+ test = (KIT / "RenderTests.swift.template").read_text(encoding="utf-8")
136
+ for k, v in [("__IMPORTS__", imports), ("__MODULE__", module), ("__TOKEN__", token),
137
+ ("__W__", str(w)), ("__H__", str(h)), ("__TOP__", str(top)), ("__BOTTOM__", str(bottom))]:
138
+ test = test.replace(k, v)
139
+ test_file = work / "RenderTests.swift"
140
+ test_file.write_text(test, encoding="utf-8")
141
+ api_file = work / "BakeshotSceneAPI.swift"
142
+ api_file.write_text((KIT / "BakeshotSceneAPI.swift.template").read_text(encoding="utf-8"), encoding="utf-8")
143
+
144
+ log(f"台本にある {len(names)} 枚を焼きます({locale} / {device}\")")
145
+ # ウィジェットは公開版に処理が無い。台本に混ざっていると、
146
+ # ウィジェットの型が見つからずビルドごと落ちる。**先に、はっきり止める。**
147
+ if ".widget" in scenes_file.read_text(encoding="utf-8"):
148
+ _used_widgets = True
149
+ if not features.pro() and _used_widgets:
150
+ raise SystemExit(
151
+ "台本にウィジェットがあります。ウィジェットは完全版の機能です。\n"
152
+ f" 買う: {'https://bakeshot.lemonsqueezy.com'}\n"
153
+ " 今すぐ焼くなら: Bakeshot/Scenes.swift から size: .widget… の行と、\n"
154
+ " そこで使っている下ごしらえを消してください(アプリの画面だけになります)。")
155
+
156
+ # 1) xcodeproj を複製してテストターゲットを足す
157
+ # 使い捨ての写し。**君のプロジェクトには一切書き込まない。**
158
+ # 同じ場所に置くのは、xcodeproj の中のファイル参照が相対パスだから(動かすと全部壊れる)。
159
+ # 先頭のドットで隠しておき、終わったら消す。
160
+ dst = project.with_name("." + project.stem + "-bakeshot.xcodeproj")
161
+ xcode.close_project(dst)
162
+ code, out = xcode.run(["/usr/bin/env", "ruby", str(KIT / "add_test_target.rb"),
163
+ str(project), str(dst),
164
+ ":".join([str(test_file), str(api_file), str(scenes_file)]),
165
+ app_target, bundle_id, "1" if strip_ext else "0",
166
+ "1" if strip_ent else "0", locale, team,
167
+ str(work / "widget"), features.widget_support_rb()],
168
+ env_extra=xcode.UTF8)
169
+ if code != 0:
170
+ shutil.rmtree(dst, ignore_errors=True)
171
+ if "cannot load such file -- xcodeproj" in out:
172
+ raise SystemExit("Ruby の xcodeproj gem がありません。`gem install --user-install xcodeproj` を実行してください")
173
+ raise SystemExit(f"xcodeproj の複製に失敗:\n{out[-800:]}")
174
+
175
+ # 2) 先にビルドだけ通す(Xcode 経由だとコンパイルエラーが読めない)。
176
+ # 引数が要る View が台本に混ざっていたら、その行を外して組み直す
177
+ try:
178
+ dest_id = xcode.mac_destination_id(dst)
179
+ except SystemExit:
180
+ shutil.rmtree(dst, ignore_errors=True)
181
+ raise
182
+ derived = work / "dd"
183
+ pruned = []
184
+ for attempt in range(1, 5):
185
+ code, out = xcode.build_for_testing(dst, dest_id, derived)
186
+ if code == 0:
187
+ break
188
+ errs = error_lines(out)
189
+ lines = test_file.read_text(encoding="utf-8").splitlines()
190
+ hits = failing_shot_lines(errs, lines)
191
+ if not hits or attempt == 4:
192
+ why = " / ".join(re.sub(r"^.*?: error: ", "", l) for l in errs[:4])
193
+ if not keep:
194
+ shutil.rmtree(dst, ignore_errors=True)
195
+ raise SystemExit(f"ビルドに失敗しました:\n{why or out[-600:]}")
196
+ dropped = []
197
+ for n, name in hits:
198
+ if name not in pruned:
199
+ pruned.append(name)
200
+ dropped.append(name)
201
+ lines[n - 1] = ""
202
+ test_file.write_text("\n".join(lines), encoding="utf-8")
203
+ log(f"コンパイルが通らないので外しました: {', '.join(dropped)}", "!")
204
+ for e in errs[:4]:
205
+ log(re.sub(r"^.*?: error: ", "", e), " ")
206
+
207
+ # 3) Xcode に走らせ、進捗ファイルで打ち切る。
208
+ # 落ちる View があるとテストの完了が返ってこないので、Xcode の完了は当てにしない。
209
+ out_dir_final = out_root / locale
210
+ out_dir_final.mkdir(parents=True, exist_ok=True)
211
+ remaining = [n for n in names if n not in pruned]
212
+ failed, collected, rounds = [], 0, 0
213
+ start_s = str(KIT / "start_test.applescript")
214
+ stop_s = str(KIT / "stop_test.applescript")
215
+ try:
216
+ while remaining and rounds < len(names) + 2:
217
+ rounds += 1
218
+ log("Xcode で My Mac (Designed for iPad) を実行しています…" if rounds == 1
219
+ else f"残り {len(remaining)} 枚でもう一周({rounds} 回目)")
220
+ box = find_out_dir(token)
221
+ if box:
222
+ for f in ("STATE", "LOG", "STDERR"):
223
+ (box / f).unlink(missing_ok=True)
224
+ # 落ちた絵は飛ばす。ソースではなくこのファイルで伝える(再ビルドを起こさない)
225
+ # 撮影側は _light / _dark を落とした名前で見ている。合わせないと素通りする
226
+ skips = skip_names(failed + pruned)
227
+ (box / "SKIP").write_text("\n".join(skips), encoding="utf-8")
228
+ code, out = xcode.osascript([start_s, str(dst), "BakeshotRender"])
229
+ if "not loaded" in out:
230
+ raise SystemExit("Xcode がプロジェクトを開けませんでした\n うまくいかない時は、Xcode を一度終了してからやり直してください。")
231
+
232
+ last, last_change, finished = "", time.time(), False
233
+ deadline = time.time() + (1200 if rounds == 1 else 600)
234
+ while time.time() < deadline:
235
+ time.sleep(1)
236
+ box = box or find_out_dir(token)
237
+ if not box:
238
+ continue
239
+ now = (box / "STATE").read_text(encoding="utf-8") if (box / "STATE").exists() else ""
240
+ if now != last:
241
+ last, last_change = now, time.time()
242
+ if now.startswith("FINISHED"):
243
+ finished = True
244
+ break
245
+ if last and time.time() - last_change > 30:
246
+ break
247
+ xcode.osascript([stop_s, str(dst)])
248
+ time.sleep(3) # 止まり切る前に次を走らせると、Xcode が受け付けない
249
+ if not box:
250
+ raise SystemExit("Xcode でテストが始まりませんでした(ビルドが通っていない可能性)\n うまくいかない時は、Xcode を一度終了してからやり直してください。")
251
+
252
+ done = set()
253
+ for f in sorted(box.glob("*.png")):
254
+ shutil.copy2(f, out_dir_final / f.name)
255
+ f.unlink()
256
+ collected += 1
257
+ done.add(f.stem)
258
+ fresh = [n for n in remaining if n in done]
259
+ for n in fresh:
260
+ log(f"✓ {n}", "")
261
+ remaining = [n for n in remaining if n not in done]
262
+ if finished:
263
+ break
264
+
265
+ parts = last.strip().split(" ")
266
+ if len(parts) == 2 and parts[0] in ("STARTED", "TIMEOUT") and parts[1] in remaining:
267
+ remaining.remove(parts[1])
268
+ failed.append(parts[1])
269
+ why = crash_reason(box)
270
+ log(f"✗ {parts[1]} は焼いている最中に"
271
+ f"{'固まった' if parts[0] == 'TIMEOUT' else '落ちた'}ので外しました"
272
+ + (f"\n {why}" if why else ""), "!")
273
+ elif not last and not done:
274
+ raise SystemExit(f"Xcode でテストが動きませんでした({rounds} 回目)"
275
+ " うまくいかない時は、Xcode を一度終了してからやり直してください。")
276
+ elif not fresh:
277
+ tail = ""
278
+ if (box / "LOG").exists():
279
+ tail = " / ".join(box.read_text if False else
280
+ (box / "LOG").read_text(encoding="utf-8").splitlines()[-4:])
281
+ log(f"これ以上進みません。残り {len(remaining)} 枚は焼けませんでした(最後の様子: {tail or '記録なし'})", "!")
282
+ failed += remaining
283
+ remaining = []
284
+ finally:
285
+ xcode.osascript([stop_s, str(dst), "close"])
286
+ if not keep:
287
+ shutil.rmtree(dst, ignore_errors=True)
288
+ shutil.rmtree(work / "dd", ignore_errors=True)
289
+
290
+ log(f"{collected} 枚できました → {out_dir_final}", "✓")
291
+ if failed or pruned:
292
+ log(f"焼けなかった: {', '.join(sorted(set(failed + pruned)))}", "!")
293
+ return collected