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.
- bakeshot-0.1.0/LICENSE +21 -0
- bakeshot-0.1.0/PKG-INFO +165 -0
- bakeshot-0.1.0/README.md +149 -0
- bakeshot-0.1.0/bakeshot/__init__.py +1 -0
- bakeshot-0.1.0/bakeshot/bake.py +293 -0
- bakeshot-0.1.0/bakeshot/cli.py +165 -0
- bakeshot-0.1.0/bakeshot/devices.py +25 -0
- bakeshot-0.1.0/bakeshot/doctor.py +55 -0
- bakeshot-0.1.0/bakeshot/features.py +32 -0
- bakeshot-0.1.0/bakeshot/hostkit/BakeshotSceneAPI.swift.template +64 -0
- bakeshot-0.1.0/bakeshot/hostkit/RenderTests.swift.template +112 -0
- bakeshot-0.1.0/bakeshot/hostkit/add_test_target.rb +140 -0
- bakeshot-0.1.0/bakeshot/hostkit/agent_guide.md +73 -0
- bakeshot-0.1.0/bakeshot/hostkit/run_in_xcode.applescript +40 -0
- bakeshot-0.1.0/bakeshot/hostkit/skill/SKILL.md +116 -0
- bakeshot-0.1.0/bakeshot/hostkit/start_test.applescript +46 -0
- bakeshot-0.1.0/bakeshot/hostkit/stop_test.applescript +26 -0
- bakeshot-0.1.0/bakeshot/license.py +66 -0
- bakeshot-0.1.0/bakeshot/scenes.py +106 -0
- bakeshot-0.1.0/bakeshot/usage.py +94 -0
- bakeshot-0.1.0/bakeshot/xcode.py +60 -0
- bakeshot-0.1.0/bakeshot.egg-info/PKG-INFO +165 -0
- bakeshot-0.1.0/bakeshot.egg-info/SOURCES.txt +32 -0
- bakeshot-0.1.0/bakeshot.egg-info/dependency_links.txt +1 -0
- bakeshot-0.1.0/bakeshot.egg-info/entry_points.txt +2 -0
- bakeshot-0.1.0/bakeshot.egg-info/top_level.txt +1 -0
- bakeshot-0.1.0/pyproject.toml +32 -0
- bakeshot-0.1.0/setup.cfg +4 -0
- bakeshot-0.1.0/tests/test_config.py +43 -0
- bakeshot-0.1.0/tests/test_devices.py +20 -0
- bakeshot-0.1.0/tests/test_prune.py +50 -0
- bakeshot-0.1.0/tests/test_scenes.py +68 -0
- bakeshot-0.1.0/tests/test_skip_names.py +58 -0
- 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.
|
bakeshot-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
bakeshot-0.1.0/README.md
ADDED
|
@@ -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
|