picoride 0.1.0

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.
@@ -0,0 +1,1156 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sinatra/base"
4
+ require "json"
5
+ require "open3"
6
+ require "shellwords"
7
+ require "fileutils"
8
+ require "uri"
9
+ require_relative "background_job"
10
+ require_relative "r2p2_state"
11
+ require_relative "project_store"
12
+
13
+ module Picoride
14
+ class Server < Sinatra::Base
15
+ # 静的ファイル・ERBテンプレート・Funicular(ブラウザ側Ruby)はgemルートの
16
+ # web/ 配下に置いている(lib/はサーバ側のRubyコード専用、exe/は実行ファイル専用)。
17
+ WEB_ROOT = File.expand_path("../../web", __dir__)
18
+
19
+ set :public_folder, File.join(WEB_ROOT, "public")
20
+ set :views, File.join(WEB_ROOT, "views")
21
+
22
+ if settings.development?
23
+ set :static_cache_control, [:no_store, :no_cache, :must_revalidate]
24
+
25
+ before do
26
+ cache_control :no_store, :no_cache, :must_revalidate
27
+ end
28
+ else
29
+ set :static_cache_control, [:public, max_age: 3600]
30
+
31
+ before do
32
+ cache_control :public, max_age: 3600
33
+ end
34
+ end
35
+
36
+ # 編集対象のプロジェクト(app/・mrbgems/・build_config.rbを持つディレクトリ)。
37
+ # 固定ディレクトリ・環境変数での既定値は持たず、UI(メニューバーのプロジェクト
38
+ # 切り替えドロップダウン、または「ファイル」>「プロジェクトを開く」)から
39
+ # 都度ユーザーが指定して開く方式にしてある(「よくあるファイルビューアの
40
+ # ような見た目でディレクトリを探せるように」という要望でDirectoryBrowserDialog
41
+ # 経由になっている)。
42
+ #
43
+ # 以前は「プロジェクトを束ねる"ワークスペース"ディレクトリを開き、その配下の
44
+ # 複数プロジェクトから1つを選ぶ」という2階層構成だった(workspace_root +
45
+ # project_root(name)で解決)。しかし「ワークスペース=プロジェクトだった」
46
+ # というフィードバックにより、2階層は不要と判明し1階層に統合した: ここで
47
+ # 開く・切り替えるディレクトリがそのままプロジェクト本体(project_root)になる。
48
+ #
49
+ # project_rootは「現在アクティブな」1つだけを指す。known_projectsは
50
+ # 一度でも開いたプロジェクトの絶対パスを開いた順に積んだ配列で、
51
+ # メニューバーのドロップダウンの選択肢になる(複数プロジェクトを開いておいて
52
+ # ドロップダウンで切り替えたい、という要望)。
53
+ #
54
+ # project_settingsは絶対パス(known_projectsの要素)をキーに、そのプロジェクトの
55
+ # ターゲット・VM・USB Console設定を持つHash(詳細は下の「プロジェクト設定」の
56
+ # 節参照)。
57
+ #
58
+ # これら3つはいずれも`~/.picoride/project.toml`(ProjectStore)に永続化する
59
+ # (一度開いたら覚えておいてほしい、再起動しても開かれた状態で起動してほしい、
60
+ # という要望)。起動時にここで一度読み込んで初期値にし、変更するたびに
61
+ # 書き戻す(ProjectStore.save参照)。
62
+ class << self
63
+ attr_accessor :project_root, :known_projects, :project_settings
64
+ end
65
+ loaded_projects = ProjectStore.load
66
+ self.known_projects = loaded_projects[:paths]
67
+ self.project_root = loaded_projects[:active]
68
+ self.project_settings = loaded_projects[:settings]
69
+
70
+ FUNICULAR_ROOT = File.join(WEB_ROOT, "funicular")
71
+
72
+ # プロジェクトごとに独立したR2P2-ESP32のビルド状態(sdkconfig・build/・
73
+ # managed_components等)を置くディレクトリ名。「プロジェクトディレクトリの中に
74
+ # 置きたい、gitには含めたくない、UI上のファイル一覧にも出したくない」という
75
+ # 要望から、プロジェクト直下のドット始まりのディレクトリにしてある。
76
+ # `.`始まりのパスは`Dir.glob("**/*")`が中身ごと辿らない(GET /api/files参照)ので、
77
+ # 特別なフィルタ処理を書かなくてもUI上には出てこない。
78
+ R2P2_STATE_DIRNAME = ".r2p2-esp32"
79
+
80
+ # Raspberry Pi Pico用のビルド状態(picoruby本体のチェックアウト。R2P2は
81
+ # picorubyに`picoruby-r2p2` gemとして統合されたため。r2p2_state.rb参照)。
82
+ # ESP32用と同じ理由でプロジェクト直下のドット始まりのディレクトリにしてある。
83
+ PICO_STATE_DIRNAME = ".r2p2-pico"
84
+
85
+ # ターゲット(設定ダイアログの「ターゲット」)をファミリーごとに分けたもの。
86
+ # ファミリーによって使うビルド用イメージ・状態ディレクトリ・ビルド手順が切り替わる。
87
+ PLATFORM_FAMILIES = {
88
+ "esp32" => %w[esp32 esp32c3 esp32c6 esp32h2 esp32p4 esp32s3],
89
+ "pico" => %w[pico pico_w pico2 pico2_w]
90
+ }.freeze
91
+ PLATFORM_TARGETS = PLATFORM_FAMILIES.values.flatten.freeze
92
+
93
+ # ターゲット未設定のプロジェクトは従来通りESP32として扱う(Pico対応以前に
94
+ # 作られたプロジェクトの挙動を変えないため)。
95
+ DEFAULT_PLATFORM_FAMILY = "esp32"
96
+
97
+ # ファミリーごとのビルド専用イメージ(docker/esp32/Dockerfile・docker/pico/Dockerfile)。
98
+ # どちらも事前に`docker build`しておく必要がある(BUILDER_DOCKERFILE_DIRS参照)。
99
+ # ESP32側は旧来の環境変数PICORUBY_BUILDER_IMAGEでも上書きできるようにしてある。
100
+ BUILDER_IMAGES = {
101
+ "esp32" => ENV["PICORIDE_ESP32_BUILDER_IMAGE"] || ENV["PICORUBY_BUILDER_IMAGE"] || "picoride-builder-esp32",
102
+ "pico" => ENV["PICORIDE_PICO_BUILDER_IMAGE"] || "picoride-builder-pico"
103
+ }.freeze
104
+ BUILDER_DOCKERFILE_DIRS = { "esp32" => "docker/esp32", "pico" => "docker/pico" }.freeze
105
+
106
+ # ビルド専用コンテナの中で、プロジェクト本体・ビルド状態(R2P2-ESP32または
107
+ # picoruby)をそれぞれbind mountする先の固定パス。ホスト側の実パスとコンテナ内
108
+ # パスの対応はこれらの定数を通じて一箇所にまとめておく(builder_docker_command参照)。
109
+ CONTAINER_PROJECT_MOUNT = "/project_src"
110
+ CONTAINER_R2P2_MOUNT = "/R2P2-ESP32"
111
+ CONTAINER_PICORUBY_MOUNT = "/picoruby"
112
+ CONTAINER_STATE_MOUNTS = { "esp32" => CONTAINER_R2P2_MOUNT, "pico" => CONTAINER_PICORUBY_MOUNT }.freeze
113
+
114
+ ALLOWED_EXTENSIONS = %w[.rb .c .h .rake].freeze
115
+
116
+ # mrbgem名として許可する文字種。ディレクトリ名としてそのまま使うので、
117
+ # パストラバーサルや意図しない特殊文字を避けるため英数字・アンダースコア・
118
+ # ハイフンのみに絞ってある。
119
+ MRBGEM_NAME_PATTERN = /\A[A-Za-z0-9_-]+\z/.freeze
120
+
121
+ # 1プロジェクト = app/・mrbgems/・build_config.rbをまとめて持つディレクトリ、
122
+ # という単位にしてある。
123
+ APP_DIRNAME = "app"
124
+ MRBGEMS_DIRNAME = "mrbgems"
125
+
126
+ # 「ファイル」メニューからの新規ファイル/新規フォルダ作成は、実機の起動スクリプト
127
+ # である app/ 配下だけに限定する(mrbgems/ 以下は「mrbgemを追加」が専用の雛形を
128
+ # 用意するので対象外)。拡張子もRubyの.rbのみに絞る(app/はR2P2起動時にmain_task.rb
129
+ # がloadするRubyスクリプト置き場であり、.c/.h/.rakeを置く場所ではないため)。
130
+ NEW_FILE_EXTENSION = ".rb"
131
+
132
+ # プロジェクト固有のビルド設定。ユーザーが直接編集できる普通のファイルという位置づけ
133
+ # (以前はGemsダイアログが生成する専用ファイルだったが、mrbgemがプロジェクトの中に
134
+ # 物理的に入るようになったことで、追加するmrbgemを選ぶUIは不要になった)。
135
+ BUILD_CONFIG_FILENAME = "build_config.rb"
136
+
137
+ # ビルド時にPROJECT_BUILD_CONFIG経由でR2P2-ESP32へ実際に渡すファイル。
138
+ # mrbgems/以下から自動生成した`conf.gem gemdir:`の並びの後ろに、プロジェクトの
139
+ # build_config.rbの内容をそのまま連結したもの(ユーザーが書いたbuild_config.rb自体は
140
+ # 書き換えない)。
141
+ GENERATED_BUILD_CONFIG_FILENAME = ".build_config.generated.rb"
142
+
143
+ BUILD_JOB = BackgroundJob.new
144
+ PLATFORM_JOB = BackgroundJob.new
145
+
146
+ helpers do
147
+ def project_root
148
+ self.class.project_root
149
+ end
150
+
151
+ def known_projects
152
+ self.class.known_projects
153
+ end
154
+
155
+ def project_settings
156
+ self.class.project_settings
157
+ end
158
+
159
+ # 現在のプロジェクト(project_root)の設定。未保存(そのプロジェクトを
160
+ # 一度も設定したことがない)なら「何も設定されていない」扱い
161
+ # (platform/vmはnil、usb_consoleはfalse)にする。
162
+ def read_project_config
163
+ project_settings[project_root] || { "platform" => nil, "vm" => nil, "usb_console" => false }
164
+ end
165
+
166
+ # path traversal (../ などによる範囲外アクセス) を防ぎつつ絶対パスに変換する
167
+ def safe_path(root, rel_path)
168
+ full = File.expand_path(File.join(root, rel_path.to_s))
169
+ root_with_sep = root + File::SEPARATOR
170
+ unless full == root || full.start_with?(root_with_sep)
171
+ raise ArgumentError, "invalid path"
172
+ end
173
+ full
174
+ end
175
+
176
+ # プロジェクトのR2P2-ESP32状態ディレクトリの絶対パス。
177
+ def r2p2_state_dir(root)
178
+ File.join(root, R2P2_STATE_DIRNAME)
179
+ end
180
+
181
+ # ターゲット名 → ファミリー("esp32"/"pico")。未設定はDEFAULT_PLATFORM_FAMILY。
182
+ def platform_family(platform)
183
+ return DEFAULT_PLATFORM_FAMILY if platform.to_s.empty?
184
+
185
+ PLATFORM_FAMILIES.each { |family, targets| return family if targets.include?(platform) }
186
+ DEFAULT_PLATFORM_FAMILY
187
+ end
188
+
189
+ # 現在のプロジェクト設定のファミリー。
190
+ def current_platform_family
191
+ platform_family(read_project_config["platform"])
192
+ end
193
+
194
+ # ファミリーごとのビルド状態ディレクトリの絶対パス。
195
+ def build_state_dir(root, family)
196
+ File.join(root, family == "pico" ? PICO_STATE_DIRNAME : R2P2_STATE_DIRNAME)
197
+ end
198
+
199
+ # .gitignoreを(プロジェクト内に無ければ作成、あれば足りない行だけ追記して)
200
+ # 用意する。R2P2-ESP32の状態やビルド生成物をプロジェクトのgit管理に含めたくない、
201
+ # という要望から、プロジェクトの中に状態を置くようにした以上セットで必要になる。
202
+ # 既存の.gitignoreの中身(ユーザー自身が書いた分)は壊さない。
203
+ def ensure_project_gitignore(root)
204
+ entries = ["#{R2P2_STATE_DIRNAME}/", "#{PICO_STATE_DIRNAME}/", GENERATED_BUILD_CONFIG_FILENAME]
205
+ path = File.join(root, ".gitignore")
206
+ existing = File.file?(path) ? File.read(path) : ""
207
+ existing_lines = existing.lines.map(&:chomp)
208
+ missing = entries.reject { |entry| existing_lines.include?(entry) }
209
+ return if missing.empty?
210
+
211
+ File.open(path, "a") do |f|
212
+ f.puts if !existing.empty? && !existing.end_with?("\n")
213
+ missing.each { |entry| f.puts(entry) }
214
+ end
215
+ end
216
+
217
+ # 新しく開いた(あるいは初めて開く)プロジェクトに、起動スクリプトの土台
218
+ # (app/app.rb)とビルド設定ファイル(build_config.rb)を空で用意しておく。
219
+ # 以前の「新しいプロジェクト」(ワークスペース配下に名前でサブディレクトリを
220
+ # 作る機能)が担っていた雛形作成を、「プロジェクトを開く」
221
+ # (DirectoryBrowserDialogで任意のディレクトリを指定・新規フォルダ作成して
222
+ # 開く)に統合したもの。
223
+ #
224
+ # **空のディレクトリのときだけ**生成する(rootが完全に空かどうかをDir.empty?で
225
+ # 判定)。最初は「app/app.rb・build_config.rbが無ければ(他に何があっても)
226
+ # 作る」という実装にしていたが、間違って1階層上の(本来のプロジェクトを
227
+ # サブディレクトリとして含む)ディレクトリを開いてしまった場合に、そこへ
228
+ # 無関係な空のapp/・build_config.rbを生成してしまい、ファイルツリーに
229
+ # 「正しいプロジェクトのサブディレクトリ」と「誤って生成された空のapp/」が
230
+ # 並んで表示される、という実際の事故が起きた。ディレクトリに何かしら既存の
231
+ # 中身がある場合は「間違ったディレクトリを開いた可能性がある」とみなして
232
+ # 何もしない(空ディレクトリ=新規作成したばかりで明らかに安全、という
233
+ # ケースだけに絞る)。
234
+ def ensure_project_scaffold(root)
235
+ return unless Dir.empty?(root)
236
+
237
+ FileUtils.mkdir_p(File.join(root, APP_DIRNAME))
238
+ File.write(File.join(root, APP_DIRNAME, "app.rb"), "")
239
+ File.write(File.join(root, BUILD_CONFIG_FILENAME), "")
240
+ end
241
+
242
+ def json_error(status, message)
243
+ halt status, { error: message }.to_json
244
+ end
245
+
246
+ def valid_name?(name)
247
+ name.is_a?(String) && MRBGEM_NAME_PATTERN.match?(name)
248
+ end
249
+
250
+ # 新規ファイル/フォルダの作成・削除で使う相対パスのチェック。safe_pathの
251
+ # パストラバーサル対策に加えて、ドット始まりのセグメント(R2P2_STATE_DIRNAME・
252
+ # GENERATED_BUILD_CONFIG_FILENAME等、UI上のファイル一覧に出さない前提の
253
+ # ものと衝突しうる)や空セグメントを拒否する。
254
+ def valid_relative_path?(rel)
255
+ return false if rel.nil? || rel.empty?
256
+
257
+ segments = rel.split("/")
258
+ segments.each do |seg|
259
+ return false if seg.empty? || seg == "." || seg == ".." || seg.start_with?(".")
260
+ end
261
+ true
262
+ end
263
+
264
+ # 新規ファイル/新規フォルダの作成先を app/ 配下に限定するチェック。
265
+ # "app" 自身(プロジェクト作成時に既に存在する)は対象外で、その下に
266
+ # 最低1階層のセグメントが必要。
267
+ def under_app_dir?(rel)
268
+ prefix = "#{APP_DIRNAME}/"
269
+ rel.start_with?(prefix) && rel.length > prefix.length
270
+ end
271
+ end
272
+
273
+ # プロジェクトが開かれていなくても使える/api/*(プロジェクト自体を開くための
274
+ # エンドポイント群)。残りの/api/*はプロジェクト(project_root)が開かれていないと
275
+ # 処理できないので、ここで先に弾いておくことで、各ルートで個別にnilチェックを
276
+ # 書かずに済む。
277
+ PROJECT_EXEMPT_API_PATHS = ["/api/project", "/api/browse"].freeze
278
+
279
+ # パターン付きbefore(例: `before %r{...}`)にすると、Sinatraがそのパターンを
280
+ # ルーティングエンジン(Mustermann)でコンパイルしようとする。Mustermannは
281
+ # 独自に文字列全体へのアンカーを付与する実装になっていて、渡した正規表現が
282
+ # (`\A`/`\z`はおろか`^`/`$`でも)既にアンカーを含むと
283
+ # `Mustermann::CompileError`になる(実際に起動して確認した)。パターン無しの
284
+ # `before do ... end`にして、Mustermannを経由しない素のRubyの文字列比較で
285
+ # パスを判定する。
286
+ before do
287
+ next unless request.path_info.start_with?("/api/") &&
288
+ !PROJECT_EXEMPT_API_PATHS.include?(request.path_info)
289
+
290
+ content_type :json
291
+ json_error(400, "project is not open") unless project_root
292
+ end
293
+
294
+ get "/" do
295
+ erb :index
296
+ end
297
+
298
+ # Funicular アプリの Ruby ソース。
299
+ get %r{/ruby/(.+\.rb)} do |rel|
300
+ full = File.expand_path(File.join(FUNICULAR_ROOT, "ruby", rel))
301
+ root_with_sep = File.join(FUNICULAR_ROOT, "ruby") + File::SEPARATOR
302
+
303
+ json_error(400, "invalid path") unless full.start_with?(root_with_sep)
304
+ halt 404 unless File.file?(full)
305
+
306
+ content_type "text/plain", charset: "utf-8"
307
+ File.read(full)
308
+ end
309
+
310
+ # 現在開いているプロジェクト(絶対パス、未設定ならnull)と、このセッションで
311
+ # 一度でも開いたプロジェクトの一覧(known、メニューバーのドロップダウンの
312
+ # 選択肢)を返す。before フィルタの対象外にしてある唯一の/api/*エンドポイント。
313
+ get "/api/project" do
314
+ content_type :json
315
+ { path: project_root, known: known_projects }.to_json
316
+ end
317
+
318
+ # プロジェクトを開く(新規に指定した場合)、または既にknown_projectsに
319
+ # あるものへ切り替える(どちらも同じエンドポイントで、違いは渡されたpathが
320
+ # 既知かどうかだけ)。body(JSON): { path: "/abs/path/to/dir" }。
321
+ # 既存の(あるいはこれから使う)ディレクトリを指定するだけで良く、空の
322
+ # ディレクトリであってもensure_project_scaffoldが起動スクリプトの土台を
323
+ # 用意する。
324
+ post "/api/project" do
325
+ content_type :json
326
+
327
+ payload =
328
+ begin
329
+ JSON.parse(request.body.read)
330
+ rescue JSON::ParserError
331
+ json_error(400, "invalid json body")
332
+ end
333
+
334
+ path = payload["path"].to_s.strip
335
+ json_error(400, "path is required") if path.empty?
336
+
337
+ full = File.expand_path(path)
338
+ json_error(400, "directory does not exist") unless File.directory?(full)
339
+
340
+ ensure_project_scaffold(full)
341
+
342
+ self.class.project_root = full
343
+ self.class.known_projects << full unless known_projects.include?(full)
344
+ ProjectStore.save(active: project_root, paths: known_projects, settings: project_settings)
345
+
346
+ { status: "ok", path: full, known: known_projects }.to_json
347
+ end
348
+
349
+ # --- ディレクトリブラウザ(「プロジェクトを開く」のフォルダ選択UI) ---------
350
+ #
351
+ # プロジェクトの絶対パスを一行のテキストで入力させる代わりに、「よくある
352
+ # ファイルビューア」のようにディレクトリを辿って選べるようにするためのAPI。
353
+ # プロジェクト自体をまだ開いていない状態でも使える必要がある(これを使って
354
+ # プロジェクトを開くため)ので、PROJECT_EXEMPT_API_PATHSに/api/browseを
355
+ # 含めてある。GET /api/files等(開いているプロジェクト内の対象拡張子の
356
+ # ファイル一覧)とは別物: ここではファイルシステム上の任意の場所の
357
+ # サブディレクトリだけを列挙する(どのプロジェクトにも属さない)。
358
+
359
+ # ドット始まりのディレクトリ(.git・.cache等)は、プロジェクト内のファイル
360
+ # 一覧(GET /api/files等)と同じ理由で一覧から除外し、見た目をすっきりさせる。
361
+ def list_browse_entries(path)
362
+ names = Dir.children(path).select do |name|
363
+ !name.start_with?(".") && File.directory?(File.join(path, name))
364
+ end
365
+ names.sort.map { |name| { name: name, path: File.join(path, name) } }
366
+ end
367
+
368
+ # ファイルシステムのルート("/")では".."しても自分自身になるので、
369
+ # その場合はparentをnilにして「これ以上は上がれない」ことを表す。
370
+ def browse_parent(path)
371
+ parent = File.expand_path(File.join(path, ".."))
372
+ parent == path ? nil : parent
373
+ end
374
+
375
+ # 指定ディレクトリ(省略時はホームディレクトリ)のサブディレクトリ一覧を返す。
376
+ # 「最初はホームディレクトリが開いている状態で」という要望により、
377
+ # pathが空ならDir.home(Linux上は$HOME)を使う。
378
+ get "/api/browse" do
379
+ content_type :json
380
+
381
+ raw = params[:path].to_s.strip
382
+ full = raw.empty? ? Dir.home : File.expand_path(raw)
383
+
384
+ json_error(400, "directory does not exist") unless File.directory?(full)
385
+
386
+ entries =
387
+ begin
388
+ list_browse_entries(full)
389
+ rescue Errno::EACCES
390
+ json_error(400, "permission denied")
391
+ end
392
+
393
+ { path: full, parent: browse_parent(full), entries: entries }.to_json
394
+ end
395
+
396
+ # 新しいフォルダを作成する。body(JSON): { path: "親ディレクトリの絶対パス", name: "新しいフォルダ名" }。
397
+ # 「新しいフォルダ」ボタンを押す動機は大抵そこを開きたいからなので、
398
+ # 作成後はそのフォルダ自身をGETと同じ形(path/parent/entries)で返し、
399
+ # フロント側はそのままそこへ移動したものとして扱う。
400
+ post "/api/browse" do
401
+ content_type :json
402
+
403
+ payload =
404
+ begin
405
+ JSON.parse(request.body.read)
406
+ rescue JSON::ParserError
407
+ json_error(400, "invalid json body")
408
+ end
409
+
410
+ parent_path = payload["path"].to_s.strip
411
+ json_error(400, "path is required") if parent_path.empty?
412
+
413
+ full_parent = File.expand_path(parent_path)
414
+ json_error(400, "directory does not exist") unless File.directory?(full_parent)
415
+
416
+ name = payload["name"].to_s.strip
417
+ json_error(400, "name is required") if name.empty?
418
+ json_error(400, "invalid name") if name.include?("/") || name == "." || name == ".."
419
+
420
+ full = File.join(full_parent, name)
421
+ json_error(409, "already exists") if File.exist?(full)
422
+
423
+ begin
424
+ Dir.mkdir(full)
425
+ rescue Errno::EACCES
426
+ json_error(400, "permission denied")
427
+ end
428
+
429
+ entries =
430
+ begin
431
+ list_browse_entries(full)
432
+ rescue Errno::EACCES
433
+ []
434
+ end
435
+
436
+ { path: full, parent: full_parent, entries: entries }.to_json
437
+ end
438
+
439
+ # 編集可能なファイル一覧を返す(現在開いているプロジェクト内)
440
+ get "/api/files" do
441
+ content_type :json
442
+
443
+ files = Dir.glob(File.join(project_root, "**", "*")).select do |f|
444
+ File.file?(f) && ALLOWED_EXTENSIONS.include?(File.extname(f))
445
+ end
446
+
447
+ relative_paths = files.map { |f| f.sub(project_root + File::SEPARATOR, "") }.sort
448
+
449
+ relative_paths.to_json
450
+ end
451
+
452
+ # ディレクトリ一覧を返す。GET /api/files はファイルの並びからツリーを
453
+ # 組み立てる都合上、中身が空のディレクトリを表現できない(TreeView側は
454
+ # state[:files]から導出したノードしか描画しないため)。新規フォルダ作成
455
+ # (POST /api/folders)で空のフォルダを作れるようになったことでこれが
456
+ # 表面化するため、ディレクトリ一覧だけは別に取得できるようにしてある。
457
+ # ドット始まりのディレクトリ(.r2p2-esp32等)は GET /api/files と同じ理由
458
+ # (Dir.globの既定動作)で自動的に除外される。
459
+ get "/api/dirs" do
460
+ content_type :json
461
+
462
+ dirs = Dir.glob(File.join(project_root, "**", "*")).select { |f| File.directory?(f) }
463
+ relative_paths = dirs.map { |f| f.sub(project_root + File::SEPARATOR, "") }.sort
464
+
465
+ relative_paths.to_json
466
+ end
467
+
468
+ # 指定ファイルの内容を返す
469
+ get "/api/file" do
470
+ content_type :json
471
+
472
+ rel = params[:path]
473
+ json_error(400, "path is required") if rel.nil? || rel.empty?
474
+
475
+ full =
476
+ begin
477
+ safe_path(project_root, rel)
478
+ rescue ArgumentError
479
+ json_error(400, "invalid path")
480
+ end
481
+
482
+ json_error(404, "file not found") unless File.file?(full)
483
+ json_error(400, "unsupported file type") unless ALLOWED_EXTENSIONS.include?(File.extname(full))
484
+
485
+ { path: rel, content: File.read(full) }.to_json
486
+ end
487
+
488
+ # ファイル内容を保存する
489
+ post "/api/file" do
490
+ content_type :json
491
+
492
+ payload =
493
+ begin
494
+ JSON.parse(request.body.read)
495
+ rescue JSON::ParserError
496
+ json_error(400, "invalid json body")
497
+ end
498
+
499
+ rel = payload["path"]
500
+ content = payload["content"]
501
+
502
+ json_error(400, "path is required") if rel.nil? || rel.empty?
503
+ json_error(400, "content is required") if content.nil?
504
+
505
+ full =
506
+ begin
507
+ safe_path(project_root, rel)
508
+ rescue ArgumentError
509
+ json_error(400, "invalid path")
510
+ end
511
+
512
+ # 既存ファイルの上書きのみ許可(新規ファイル作成はサンプルでは対象外)
513
+ json_error(404, "file not found") unless File.file?(full)
514
+ json_error(400, "unsupported file type") unless ALLOWED_EXTENSIONS.include?(File.extname(full))
515
+
516
+ File.write(full, content)
517
+
518
+ { status: "ok", path: rel, bytes: content.bytesize }.to_json
519
+ end
520
+
521
+ # 新規ファイルを作成する(空ファイル)。POST /api/fileは既存ファイルの上書きしか
522
+ # 許可していないので、新規作成はこちらに分けてある。
523
+ post "/api/files" do
524
+ content_type :json
525
+
526
+ payload =
527
+ begin
528
+ JSON.parse(request.body.read)
529
+ rescue JSON::ParserError
530
+ json_error(400, "invalid json body")
531
+ end
532
+
533
+ rel = payload["path"].to_s
534
+ json_error(400, "path is required") if rel.empty?
535
+ json_error(400, "unsupported file type") unless File.extname(rel) == NEW_FILE_EXTENSION
536
+ json_error(400, "invalid path") unless valid_relative_path?(rel)
537
+ json_error(400, "files can only be created under #{APP_DIRNAME}/") unless under_app_dir?(rel)
538
+
539
+ full =
540
+ begin
541
+ safe_path(project_root, rel)
542
+ rescue ArgumentError
543
+ json_error(400, "invalid path")
544
+ end
545
+
546
+ json_error(409, "file already exists") if File.exist?(full)
547
+
548
+ FileUtils.mkdir_p(File.dirname(full))
549
+ File.write(full, "")
550
+
551
+ { status: "ok", path: rel }.to_json
552
+ end
553
+
554
+ # 新規フォルダを作成する(空ディレクトリ)。新規ファイルと同じくapp/配下のみ許可する。
555
+ post "/api/folders" do
556
+ content_type :json
557
+
558
+ payload =
559
+ begin
560
+ JSON.parse(request.body.read)
561
+ rescue JSON::ParserError
562
+ json_error(400, "invalid json body")
563
+ end
564
+
565
+ rel = payload["path"].to_s
566
+ json_error(400, "path is required") if rel.empty?
567
+ json_error(400, "invalid path") unless valid_relative_path?(rel)
568
+ json_error(400, "folders can only be created under #{APP_DIRNAME}/") unless under_app_dir?(rel)
569
+
570
+ full =
571
+ begin
572
+ safe_path(project_root, rel)
573
+ rescue ArgumentError
574
+ json_error(400, "invalid path")
575
+ end
576
+
577
+ json_error(409, "folder already exists") if File.exist?(full)
578
+
579
+ FileUtils.mkdir_p(full)
580
+
581
+ { status: "ok", path: rel }.to_json
582
+ end
583
+
584
+ # ファイルを削除する。ツリーのコンテキストメニュー(「削除」)から呼ばれる。
585
+ # 新規作成とは違いapp/配下に限定しない(mrbgemの.c/.h等、プロジェクト内の
586
+ # ALLOWED_EXTENSIONSファイルであればどこでも削除できる)。
587
+ delete "/api/files" do
588
+ content_type :json
589
+
590
+ rel = params[:path].to_s
591
+ json_error(400, "path is required") if rel.empty?
592
+
593
+ full =
594
+ begin
595
+ safe_path(project_root, rel)
596
+ rescue ArgumentError
597
+ json_error(400, "invalid path")
598
+ end
599
+
600
+ json_error(404, "file not found") unless File.file?(full)
601
+ json_error(400, "unsupported file type") unless ALLOWED_EXTENSIONS.include?(File.extname(full))
602
+
603
+ File.delete(full)
604
+
605
+ { status: "ok", path: rel }.to_json
606
+ end
607
+
608
+ # フォルダを再帰的に削除する。app/自身・mrbgems/自身は構造上必須
609
+ # (app/はR2P2起動スクリプトの置き場所、mrbgems/は「mrbgemを追加」専用の
610
+ # 置き場所)なので削除させない。UIのコンテキストメニューでもこの2つには
611
+ # 「削除」を出していないが、APIを直接叩かれた場合の防御として二重にチェックする。
612
+ delete "/api/folders" do
613
+ content_type :json
614
+
615
+ rel = params[:path].to_s
616
+ json_error(400, "path is required") if rel.empty?
617
+ json_error(400, "invalid path") unless valid_relative_path?(rel)
618
+ json_error(400, "cannot delete this folder") if rel == APP_DIRNAME || rel == MRBGEMS_DIRNAME
619
+
620
+ full =
621
+ begin
622
+ safe_path(project_root, rel)
623
+ rescue ArgumentError
624
+ json_error(400, "invalid path")
625
+ end
626
+
627
+ json_error(404, "folder not found") unless File.directory?(full)
628
+
629
+ FileUtils.rm_rf(full)
630
+
631
+ { status: "ok", path: rel }.to_json
632
+ end
633
+
634
+ # --- プロジェクト設定(ターゲット・VM・USB Console) ----------------------
635
+ #
636
+ # 以前はビルドボタンを押すたびにダイアログでVM/USB Consoleを選ばせ、ターゲット
637
+ # チップはメニューバーの常設セレクトで選ぶ形だったが、「プロジェクトの設定として
638
+ # プロジェクトに含めたい」というフィードバックを受けて、プロジェクトごとに
639
+ # 隠しファイル`.config.yml`へ永続化する方式に変更した。その後「設定はワークスペース
640
+ # (当時はプロジェクトの上位概念だった)毎に持たせてほしい」という要望で
641
+ # `~/.picoride/workspace.toml`へ移し、さらに「ワークスペース=プロジェクトだった」
642
+ # というフィードバックで2階層構成自体を1階層に統合した結果、現在は単純に
643
+ # 「開いているプロジェクトの設定」として`~/.picoride/project.toml`
644
+ # (ProjectStore、project_root/known_projectsと同じファイル)に永続化している。
645
+ # UI側は「設定」ダイアログで編集し、ビルド/プラットフォームセットアップは
646
+ # その時点の設定値をそのまま使って即座に実行するだけになる。
647
+
648
+ get "/api/project/config" do
649
+ content_type :json
650
+ read_project_config.to_json
651
+ end
652
+
653
+ # body(JSON): { platform:, vm:, usb_console: } — platform/vmはnull(未選択)も許可
654
+ post "/api/project/config" do
655
+ content_type :json
656
+
657
+ payload =
658
+ begin
659
+ JSON.parse(request.body.read)
660
+ rescue JSON::ParserError
661
+ json_error(400, "invalid json body")
662
+ end
663
+
664
+ platform = payload["platform"]
665
+ json_error(400, "invalid platform") if platform && !PLATFORM_TARGETS.include?(platform)
666
+
667
+ vm = payload["vm"]
668
+ json_error(400, "invalid vm") if vm && !BUILD_VM_FLAGS.key?(vm)
669
+ if vm && platform_family(platform) == "pico" && !PICO_BOARD_VMS.fetch(platform).include?(vm)
670
+ json_error(400, "#{platform} では #{vm} を使えません(使えるVM: #{PICO_BOARD_VMS.fetch(platform).join(', ')})")
671
+ end
672
+
673
+ config = { "platform" => platform, "vm" => vm, "usb_console" => payload["usb_console"] == true }
674
+ self.class.project_settings = project_settings.merge(project_root => config)
675
+ ProjectStore.save(active: project_root, paths: known_projects, settings: project_settings)
676
+
677
+ { status: "ok" }.to_json
678
+ end
679
+
680
+ # --- mrbgemのビルド組み込み -------------------------------------------
681
+ #
682
+ # R2P2-ESP32本体のbuild_config(components/picoruby-esp32/build_config/*.rb)は
683
+ # xtensa/riscv・femtoruby/picoruby の組み合わせで4種あり、ツールチェイン設定など
684
+ # 込み入った内容を持つ。これをプロジェクトごとに複製すると本家の変更に追従できなく
685
+ # なるため、複製はせず「本家の設定を評価した最後に、プロジェクト側の追加/除外だけを
686
+ # 差し込む」フックをDockerfileで1行だけ本家4ファイルに追加している(該当箇所は
687
+ # `conf.instance_eval(File.read(ENV['PROJECT_BUILD_CONFIG'])) if ...`)。
688
+ #
689
+ # mrbgems/以下にあるものは全部自動でビルドに含める(選ぶUIは無い)。
690
+ # プロジェクトの中に物理的にmrbgemを置く=そのプロジェクトで使う、という構造そのものが
691
+ # 選択を兼ねているので、ビルドのたびに`generated_build_config_content`で
692
+ # 「その時点でmrbgems/以下にあるgem一覧」から`conf.gem gemdir:`の並びを作り、
693
+ # その後ろにプロジェクトのbuild_config.rb(デフォルトgemを外したい場合はここに
694
+ # `conf.gems.reject!`を書く、普通の編集可能ファイル)をそのまま連結したものを
695
+ # PROJECT_BUILD_CONFIG経由で渡す。gemdir(自作mrbgemの絶対パス)は常にコンテナ内の
696
+ # 絶対パスで書く(相対パス解決の基点があいまいなmruby側の挙動に依存しないため)。
697
+
698
+ # mrbgems/直下のディレクトリ名一覧を返す(存在チェック・列挙はホスト側のrootを見る)。
699
+ def project_mrbgem_names(root)
700
+ mrbgems_dir = File.join(root, MRBGEMS_DIRNAME)
701
+ return [] unless Dir.exist?(mrbgems_dir)
702
+
703
+ Dir.children(mrbgems_dir).select { |name| File.directory?(File.join(mrbgems_dir, name)) }.sort
704
+ end
705
+
706
+ # 最小構成のmrbgem.rake(mrbgemとして成立する最低限の内容)。
707
+ def mrbgem_rake_content(name)
708
+ <<~RUBY
709
+ MRuby::Gem::Specification.new("#{name}") do |spec|
710
+ spec.license = "MIT"
711
+ spec.authors = "#{name}"
712
+ spec.summary = "#{name}"
713
+ end
714
+ RUBY
715
+ end
716
+
717
+ # mrbgems/以下に新しいmrbgemの雛形(mrbgem.rake・mrblib/<name>.rb)を作る。
718
+ # ここに置くだけで次回ビルドから自動的に組み込まれる
719
+ # (generated_build_config_content参照。選ぶUIは無い)。
720
+ post "/api/mrbgems" do
721
+ content_type :json
722
+
723
+ payload =
724
+ begin
725
+ JSON.parse(request.body.read)
726
+ rescue JSON::ParserError
727
+ json_error(400, "invalid json body")
728
+ end
729
+
730
+ gem_name = payload["name"].to_s
731
+ json_error(400, "invalid mrbgem name") unless valid_name?(gem_name)
732
+
733
+ gem_dir = File.join(project_root, MRBGEMS_DIRNAME, gem_name)
734
+ json_error(409, "mrbgem already exists") if File.exist?(gem_dir)
735
+
736
+ FileUtils.mkdir_p(File.join(gem_dir, "mrblib"))
737
+ File.write(File.join(gem_dir, "mrbgem.rake"), mrbgem_rake_content(gem_name))
738
+ File.write(File.join(gem_dir, "mrblib", "#{gem_name}.rb"), "")
739
+
740
+ { status: "ok", name: gem_name }.to_json
741
+ end
742
+
743
+ def generated_build_config_content(root)
744
+ lines = ["# picoride が自動生成する部分(#{MRBGEMS_DIRNAME}/以下のgemを追加する)"]
745
+ project_mrbgem_names(root).each do |name|
746
+ # gemdirはビルドコンテナの中から見たパス(CONTAINER_PROJECT_MOUNT配下)で書く。
747
+ # ホスト側の実パスではない点に注意(builder_docker_commandでproject_srcとしてマウントする)。
748
+ container_path = File.join(CONTAINER_PROJECT_MOUNT, MRBGEMS_DIRNAME, name)
749
+ lines << "conf.gem gemdir: #{container_path.inspect}"
750
+ end
751
+
752
+ build_config_path = File.join(root, BUILD_CONFIG_FILENAME)
753
+ if File.file?(build_config_path)
754
+ lines << ""
755
+ lines << "# ここから下は #{BUILD_CONFIG_FILENAME} の内容"
756
+ lines << File.read(build_config_path)
757
+ end
758
+
759
+ lines.join("\n") + "\n"
760
+ end
761
+
762
+ # ビルド専用コンテナ(ファミリーごとのBUILDER_IMAGES)を使い捨てで起動し、
763
+ # その中でinner_cmdを実行するコマンド文字列を組み立てる。state_dir(プロジェクト
764
+ # ごとのビルド状態。ESP32ならR2P2-ESP32、Picoならpicoruby本体)を
765
+ # CONTAINER_STATE_MOUNTS[family]に、project_root(mrbgems/やapp/を含む
766
+ # プロジェクト本体)をCONTAINER_PROJECT_MOUNTにbind mountする。
767
+ #
768
+ # ESP32では idf.py / rake が ESP-IDF の export.sh を読み込んだシェルでしか
769
+ # 使えないため、コンテナ内で明示的に読み込んでから inner_cmd を実行する
770
+ # (Pico用イメージはPATH・PICO_SDK_PATHがENVで設定済みなので不要)。
771
+ #
772
+ # コンテナは(`--user`を付けず)デフォルトのrootで動かす。rakelib/setup.rakeが
773
+ # 内部で呼ぶ`bundle install`がシステムのgemディレクトリ(/var/lib/gems/...)へ
774
+ # 書き込む必要があり、`--user <非root>`にすると権限エラーで失敗することを
775
+ # 実際に確認したため(root専用のシステムgemパスを使っている以上、非rootでは
776
+ # そもそも動かせない)。その代わり、bind mountしたstate_dir配下がroot所有のまま
777
+ # ホストに残ってプロジェクトディレクトリの持ち主(ホストユーザー)から扱いにくく
778
+ # なる問題を避けるため、コマンド完了後に必ず`chown`でホストユーザーの所有に戻す
779
+ # (`;`でつないでinner_cmdの成否に関わらず実行し、`inner_cmd`自体の終了コードは
780
+ # `$?`で保持して最後に`exit`し直すことで、chown自体の成否でBUILD_JOBの成功/失敗
781
+ # 判定が変わらないようにしている)。
782
+ def builder_docker_command(family, state_dir, project_dir, inner_cmd)
783
+ state_mount = CONTAINER_STATE_MOUNTS.fetch(family)
784
+ chown_target = "#{Process.uid}:#{Process.gid}"
785
+ env_setup = family == "esp32" ? ". \"$IDF_PATH/export.sh\" > /dev/null && " : ""
786
+ full_inner_cmd =
787
+ "#{env_setup}cd #{Shellwords.escape(state_mount)} && " \
788
+ "#{inner_cmd}; " \
789
+ "exit_code=$?; " \
790
+ "chown -R #{chown_target} #{Shellwords.escape(state_mount)}; " \
791
+ "exit $exit_code"
792
+
793
+ "docker run --rm " \
794
+ "-v #{Shellwords.escape(state_dir)}:#{state_mount} " \
795
+ "-v #{Shellwords.escape(project_dir)}:#{CONTAINER_PROJECT_MOUNT} " \
796
+ "#{Shellwords.escape(BUILDER_IMAGES.fetch(family))} " \
797
+ "bash -c #{Shellwords.escape(full_inner_cmd)}"
798
+ end
799
+
800
+ # ファミリーのビルド用イメージがローカルに存在するかどうか。無いままdocker runすると
801
+ # 分かりにくいエラーになるので、事前にチェックして案内を出せるようにする。
802
+ def builder_image_available?(family)
803
+ system("docker", "image", "inspect", BUILDER_IMAGES.fetch(family), out: File::NULL, err: File::NULL)
804
+ end
805
+
806
+ def ensure_builder_image!(family)
807
+ return if builder_image_available?(family)
808
+
809
+ image = BUILDER_IMAGES.fetch(family)
810
+ json_error(500, "ビルド用イメージ #{image} が見つかりません。" \
811
+ "picorideのディレクトリで docker build -t #{image} #{BUILDER_DOCKERFILE_DIRS.fetch(family)} を実行してください")
812
+ end
813
+
814
+ # R2P2State.ensure_checkout / ensure_pico_checkout は git clone 等に失敗すると
815
+ # 例外を投げる(R2P2State.run! 参照)。Sinatraのリクエストハンドラ内で素通しすると
816
+ # 生の500になってしまうので、json_errorでラップする。
817
+ def ensure_build_checkout!(family, state_dir)
818
+ if family == "pico"
819
+ R2P2State.ensure_pico_checkout(state_dir)
820
+ else
821
+ R2P2State.ensure_checkout(state_dir)
822
+ end
823
+ rescue StandardError => e
824
+ name = family == "pico" ? "picoruby(R2P2)" : "R2P2-ESP32"
825
+ json_error(500, "#{name}のセットアップに失敗しました: #{e.message}")
826
+ end
827
+
828
+ # R2P2-ESP32 のビルド状態(実行中/成功/失敗/未実行)とログを返す。
829
+ # UI 側はこれをポーリングして進捗を表示する。
830
+ get "/api/build" do
831
+ content_type :json
832
+ BUILD_JOB.to_response_json
833
+ end
834
+
835
+ # idf.py build -DPICORB_VM=xxx に渡すフラグ(R2P2-ESP32/Rakefile の PICORB_VMS と同じ対応)。
836
+ BUILD_VM_FLAGS = { "femtoruby" => "mrubyc", "picoruby" => "mruby" }.freeze
837
+
838
+ # Pico各ボードで使えるVM(picoruby/build_config/r2p2-{vm}-{board}.rb が存在する組み合わせ。
839
+ # RP2040(pico/pico_w)はmruby VMのビルド設定が無い)。先頭がVM未指定時の既定値。
840
+ PICO_BOARD_VMS = {
841
+ "pico" => %w[femtoruby],
842
+ "pico_w" => %w[femtoruby],
843
+ "pico2" => %w[femtoruby picoruby],
844
+ "pico2_w" => %w[femtoruby picoruby]
845
+ }.freeze
846
+
847
+ # Picoのビルドモード(`rake r2p2:{vm}:{board}:{mode}`のmode)。IDEからは
848
+ # 配布用のprodだけを使う(debugはUART経由のデバッグ出力等、開発者向け)。
849
+ PICO_BUILD_MODE = "prod"
850
+
851
+ # 外部USB-UART変換チップを持たないボード向けの設定フラグメント
852
+ # (R2P2-ESP32/sdkconfigs/usb_console、README.md「Hardware-specific Configuration」参照)。
853
+ USB_CONSOLE_SDKCONFIG_DEFAULTS = "sdkconfig.defaults;sdkconfigs/usb_console"
854
+
855
+ # 現在の sdkconfig が USB Console設定でビルドされているかどうか。
856
+ # sdkconfigが無い(セットアップ直後、または一度もビルドしていない)場合はfalse扱い。
857
+ def sdkconfig_has_usb_console?(state_dir)
858
+ sdkconfig_path = File.join(state_dir, "sdkconfig")
859
+ return false unless File.file?(sdkconfig_path)
860
+
861
+ File.foreach(sdkconfig_path).any? { |line| line.start_with?("CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y") }
862
+ end
863
+
864
+ # Picoのボード・VM設定(未指定VMは既定値)を解決する。不正な組み合わせなら400。
865
+ def pico_board_and_vm!(config)
866
+ board = config["platform"]
867
+ vms = PICO_BOARD_VMS.fetch(board)
868
+ vm = config["vm"] || vms.first
869
+ json_error(400, "#{board} では #{vm} を使えません(使えるVM: #{vms.join(', ')})") unless vms.include?(vm)
870
+ [board, vm]
871
+ end
872
+
873
+ # mrbgems/以下の現在の一覧とbuild_config.rbから、実際にビルドへ渡す
874
+ # ファイルをビルドのたびに作り直す(generated_build_config_content参照)。
875
+ # 戻り値はPROJECT_BUILD_CONFIGに渡す「コンテナ内から見たパス」の代入文
876
+ # (ホスト側の実パスではない)。
877
+ def prepare_project_build_config(root)
878
+ generated_build_config = File.join(root, GENERATED_BUILD_CONFIG_FILENAME)
879
+ File.write(generated_build_config, generated_build_config_content(root))
880
+ container_build_config = File.join(CONTAINER_PROJECT_MOUNT, GENERATED_BUILD_CONFIG_FILENAME)
881
+ "PROJECT_BUILD_CONFIG=#{Shellwords.escape(container_build_config)}"
882
+ end
883
+
884
+ # ビルドをバックグラウンドで開始する。
885
+ # 実行中に重ねて叩かれた場合は 409 を返す(同時に複数走らせない)。
886
+ # ターゲット・VM・USB Consoleはリクエストでは受け取らず、現在のプロジェクトの設定
887
+ # (read_project_config、`~/.picoride/project.toml`から読む)に保存されている
888
+ # 値をそのまま使う。ターゲットのファミリー(ESP32/Pico)によって、使うビルド用
889
+ # イメージ・状態ディレクトリ・ビルド手順が切り替わる。ビルド状態はプロジェクトごとに
890
+ # 独立している(build_state_dir)ので、初回はここでgit clone(R2P2State)が
891
+ # 走り、数分かかることがある。
892
+ post "/api/build" do
893
+ content_type :json
894
+
895
+ # BUILD_JOB/PLATFORM_JOBは別々のロックなので、これが無いと「ビルド中に別の
896
+ # リクエストでプラットフォームセットアップ(rake setup_xxxのdeep_clean)を始める」
897
+ # ことを防げず、同じプロジェクトのビルド状態ディレクトリを2つのdocker run
898
+ # コンテナが同時に書き換えてしまう(実際に発生し、ビルド中のファイルが
899
+ # セットアップのdeep_cleanで消えてErrno::ENOENTになった)。
900
+ json_error(409, "platform setup is running") if PLATFORM_JOB.running?
901
+
902
+ project_config = read_project_config
903
+ family = platform_family(project_config["platform"])
904
+ ensure_builder_image!(family)
905
+
906
+ root = project_root
907
+ state_dir = build_state_dir(root, family)
908
+ ensure_project_gitignore(root)
909
+ ensure_build_checkout!(family, state_dir)
910
+
911
+ inner_cmd =
912
+ if family == "pico"
913
+ pico_build_inner_command(root, project_config)
914
+ else
915
+ esp32_build_inner_command(root, state_dir, project_config)
916
+ end
917
+
918
+ started = BUILD_JOB.start(builder_docker_command(family, state_dir, root, inner_cmd))
919
+ json_error(409, "build already running") unless started
920
+
921
+ { status: "ok" }.to_json
922
+ end
923
+
924
+ # Pico: picorubyの`rake r2p2:{vm}:{board}:prod`(mruby/cのライブラリビルド +
925
+ # pico-sdkのcmakeビルド)を実行する。成果物の.uf2は
926
+ # build/r2p2/{vm}/{board}/prod/ にできる(pico_uf2_path参照)。
927
+ #
928
+ # ESP32と違い、app/以下をファームウェアに焼き込む仕組みは無い(R2P2はPicoの
929
+ # フラッシュをUSBマスストレージとして見せる方式で、ストレージのイメージを
930
+ # ビルド時に作らない)。app/はターミナルタブの「app/ をアップロード」
931
+ # (PicoModem)で書き込む。
932
+ #
933
+ # r2p2のcmakeはmain_task.rbのコンパイルに`<picoruby>/bin/mrbc`を使うが、
934
+ # cloneしたばかりのpicorubyにはこれが無く、r2p2タスク自体も作らない
935
+ # (`/picoruby/bin/mrbc: not found`で失敗することを実際のビルドで確認した)。
936
+ # mrbcはVMに依存しないスタンドアロンのコンパイラなので、無ければ先に
937
+ # build_config/mrbc.rb で1回だけビルドする(bin/mrbcへのシンボリックリンクができる)。
938
+ def pico_build_inner_command(root, project_config)
939
+ board, vm = pico_board_and_vm!(project_config)
940
+ "#{PICO_PREPARE_COMMAND} && " \
941
+ "{ [ -x bin/mrbc ] || MRUBY_CONFIG=build_config/mrbc.rb rake; } && " \
942
+ "#{prepare_project_build_config(root)} rake r2p2:#{vm}:#{board}:#{PICO_BUILD_MODE}"
943
+ end
944
+
945
+ # Wi-Fi付きボード(pico_w/pico2_w)のbuild_configが使うpicoruby-socket・
946
+ # picoruby-mbedtlsは、lwIP・mbedTLSをpicoruby側のpico-sdkサブモジュール
947
+ # (mrbgems/picoruby-r2p2/lib/pico-sdk/lib/{lwip,mbedtls})へのシンボリックリンク
948
+ # 経由で参照する。このサブモジュールはcloneしていない(pico-sdkはイメージ側の
949
+ # /opt/pico-sdkを使う。r2p2_state.rb参照)ので、コンテナ内でそのパスを
950
+ # $PICO_SDK_PATHへのシンボリックリンクにしておく(無いと
951
+ # `lwip/opt.h: No such file or directory`で失敗することを実際のビルドで確認した)。
952
+ # リンク先はコンテナ内のパスなので、ホスト側から見るとリンク切れになるが害は無い。
953
+ # `rake ...:clean`もbuild_configを読み込む(picoruby-socketのmrbgem.rakeは
954
+ # リンク切れだと例外を投げる)ので、セットアップでも同じ準備をする。
955
+ PICO_SDK_SUBMODULE_PATH = "mrbgems/picoruby-r2p2/lib/pico-sdk"
956
+ #
957
+ # 「中身があるか」ではなく「シンボリックリンクかどうか」で判定する。picoruby-mbedtlsの
958
+ # mrbgem.rakeは、リンク先が無いと自分でpico-sdkサブモジュールとその中のmbedtls
959
+ # だけをgit submodule update --initしてしまう(lwipは取らない)ので、一度でも
960
+ # その状態になると「ディレクトリはあるがlwipが空」になって中身チェックをすり抜ける
961
+ # (実際に起きた)。
962
+ PICO_PREPARE_COMMAND =
963
+ "{ [ -L #{PICO_SDK_SUBMODULE_PATH} ] || " \
964
+ "{ rm -rf #{PICO_SDK_SUBMODULE_PATH} && ln -s \"$PICO_SDK_PATH\" #{PICO_SDK_SUBMODULE_PATH}; }; }"
965
+
966
+ # ESP32: idf.py build。
967
+ def esp32_build_inner_command(root, state_dir, project_config)
968
+ # プロジェクトのapp/以下を実機の起動スクリプトにする(storage/home/を参照する
969
+ # R2P2本来の仕組み。詳細は上のコメント参照)。前回このプロジェクトをビルドした
970
+ # ときの残骸が残らないよう、まずstorage/home/を空にしてからコピーし直す。
971
+ storage_home_dir = File.join(state_dir, "storage", "home")
972
+ FileUtils.mkdir_p(storage_home_dir)
973
+ FileUtils.rm_rf(Dir.glob(File.join(storage_home_dir, "*")))
974
+ project_app_dir = File.join(root, APP_DIRNAME)
975
+ if Dir.exist?(project_app_dir)
976
+ FileUtils.cp_r(Dir.glob(File.join(project_app_dir, "*")), storage_home_dir)
977
+ end
978
+
979
+ vm = project_config["vm"]
980
+ usb_console = project_config["usb_console"]
981
+
982
+ build_cmd = +"idf.py build"
983
+ build_cmd << " -DPICORB_VM=#{BUILD_VM_FLAGS[vm]}" if vm
984
+
985
+ env_assignments = [prepare_project_build_config(root)]
986
+
987
+ if usb_console != sdkconfig_has_usb_console?(state_dir)
988
+ # SDKCONFIG_DEFAULTS は sdkconfig ファイルが無いときにしか読まれない仕組みなので、
989
+ # 現在の設定と要求された設定が食い違うときだけ sdkconfig を消してビルドし直す
990
+ # (README.md「If you change SDKCONFIG_DEFAULTS, delete the sdkconfig file and
991
+ # rebuild from scratch」参照。fullclean/deep_cleanでも消えないので明示的に消す)。
992
+ # 値が変わらない限りはこのクリーンビルドを避け、従来通りの差分ビルドのままにする。
993
+ sdkconfig_defaults = usb_console ? USB_CONSOLE_SDKCONFIG_DEFAULTS : "sdkconfig.defaults"
994
+ env_assignments << "SDKCONFIG_DEFAULTS=#{Shellwords.escape(sdkconfig_defaults)}"
995
+ "rm -f sdkconfig && #{env_assignments.join(' ')} #{build_cmd}"
996
+ else
997
+ "#{env_assignments.join(' ')} #{build_cmd}"
998
+ end
999
+ end
1000
+
1001
+ # プラットフォーム(ターゲットチップ)セットアップの実行状態とログを返す。
1002
+ get "/api/platform" do
1003
+ content_type :json
1004
+ PLATFORM_JOB.to_response_json
1005
+ end
1006
+
1007
+ # 現在のプロジェクトの設定に設定されているターゲットのセットアップを
1008
+ # バックグラウンドで実行する。ターゲット自体はリクエストでは受け取らず、
1009
+ # 設定ダイアログで保存された値を使う。
1010
+ #
1011
+ # - ESP32: `rake setup_#{platform}`。deep_clean + setup(mrubyの再ビルド) +
1012
+ # idf.py set-target という重い処理の直列実行(R2P2-ESP32/rakelib/setup.rake参照)。
1013
+ # - Pico: `rake r2p2:{vm}:{board}:clean`。Picoはボード×VMごとにビルド
1014
+ # ディレクトリが分かれていてターゲット切り替えの準備は不要なので、
1015
+ # 「そのボードのビルド成果物を消してクリーンな状態からビルドし直せるようにする」
1016
+ # だけ(picorubyのチェックアウト自体はensure_build_checkout!で行われる)。
1017
+ post "/api/platform" do
1018
+ content_type :json
1019
+
1020
+ project_config = read_project_config
1021
+ platform = project_config["platform"]
1022
+ json_error(400, "platform is not configured for this project") if platform.to_s.empty?
1023
+
1024
+ # BUILD_JOBとの同時実行を防ぐ理由はPOST /api/build側のコメント参照。
1025
+ json_error(409, "build is running") if BUILD_JOB.running?
1026
+
1027
+ family = platform_family(platform)
1028
+ ensure_builder_image!(family)
1029
+
1030
+ root = project_root
1031
+ state_dir = build_state_dir(root, family)
1032
+ ensure_project_gitignore(root)
1033
+ ensure_build_checkout!(family, state_dir)
1034
+
1035
+ inner_cmd =
1036
+ if family == "pico"
1037
+ board, vm = pico_board_and_vm!(project_config)
1038
+ "#{PICO_PREPARE_COMMAND} && rake r2p2:#{vm}:#{board}:clean"
1039
+ else
1040
+ "rake setup_#{platform}"
1041
+ end
1042
+
1043
+ started = PLATFORM_JOB.start(builder_docker_command(family, state_dir, root, inner_cmd))
1044
+ json_error(409, "platform setup already running") unless started
1045
+
1046
+ { status: "ok", platform: platform }.to_json
1047
+ end
1048
+
1049
+ # デバイスへの書き込み(インストール)は ESP Web Tools
1050
+ # (https://esphome.github.io/esp-web-tools/、index.html でCDN読み込み)経由の
1051
+ # ブラウザのWeb Serial APIで行う。サーバはビルド成果物からマニフェストと.binを
1052
+ # 配信するだけで、実際の書き込み処理はブラウザ側(esp-web-install-button)が担う。
1053
+ # ビルド成果物は現在のプロジェクトのR2P2-ESP32状態ディレクトリ(r2p2_state_dir)の下、
1054
+ # `build/`にある。
1055
+
1056
+ # idf.py set-target のターゲット名 → ESP Web Tools の chipFamily 名。
1057
+ # ESP Web Tools が対応しているチップ一覧(esp-web-tools/src/const.ts の Build#chipFamily)
1058
+ # のうち、R2P2-ESP32側がサポートしている(PLATFORM_TARGETSにある)ものだけ載せてある。
1059
+ CHIP_FAMILY_MAP = {
1060
+ "esp32" => "ESP32",
1061
+ "esp32c3" => "ESP32-C3",
1062
+ "esp32c6" => "ESP32-C6",
1063
+ "esp32h2" => "ESP32-H2",
1064
+ "esp32p4" => "ESP32-P4",
1065
+ "esp32s3" => "ESP32-S3"
1066
+ }.freeze
1067
+
1068
+ def firmware_build_dir(root)
1069
+ File.join(r2p2_state_dir(root), "build")
1070
+ end
1071
+
1072
+ # --- Raspberry Pi Pico のファームウェア(.uf2) ---------------------------
1073
+ #
1074
+ # PicoはESP Web Toolsのような「ブラウザから直接書き込む」仕組みを使わず、
1075
+ # BOOTSELボタンを押しながら接続すると現れるUSBドライブ(RPI-RP2 / RP2350)へ
1076
+ # .uf2をコピーするだけで書き込める。そのためサーバはビルド成果物の.uf2を
1077
+ # ダウンロードさせるだけにしてある(UI側はメニューバーの「UF2をダウンロード」)。
1078
+
1079
+ # 現在のプロジェクト設定(ボード・VM)に対応する最新の.uf2の絶対パス(無ければnil)。
1080
+ # ファイル名にビルド日付・リビジョンが入る(R2P2-FEMTORUBY-<version>-PICO-<date>-<rev>.uf2)
1081
+ # ため過去のビルドの.uf2が残りうるので、更新日時が最も新しいものを選ぶ。
1082
+ def pico_uf2_path(root, board, vm)
1083
+ dir = File.join(build_state_dir(root, "pico"), "build", "r2p2", vm, board, PICO_BUILD_MODE)
1084
+ Dir.glob(File.join(dir, "*.uf2")).max_by { |path| File.mtime(path) }
1085
+ end
1086
+
1087
+ get "/api/firmware/uf2" do
1088
+ project_config = read_project_config
1089
+ json_error(400, "target is not a Raspberry Pi Pico") unless current_platform_family == "pico"
1090
+
1091
+ board, vm = pico_board_and_vm!(project_config)
1092
+ path = pico_uf2_path(project_root, board, vm)
1093
+ json_error(404, "not built yet") unless path
1094
+
1095
+ send_file path, type: "application/octet-stream", disposition: "attachment", filename: File.basename(path)
1096
+ end
1097
+
1098
+ # ESP Web Tools 用のマニフェストを、直近のビルド成果物
1099
+ # (build/project_description.json の "target" と build/flash_args)から動的に組み立てる。
1100
+ # flash_args は `idf.py build` が生成する、esptool write_flash にそのまま渡せる
1101
+ # "<オフセット(16進)> <binへの相対パス>" の行の並び(1行目は --flash_mode 等のオプション行)。
1102
+ get "/api/firmware/manifest.json" do
1103
+ content_type :json
1104
+
1105
+ json_error(400, "target is not an ESP32") unless current_platform_family == "esp32"
1106
+
1107
+ root = project_root
1108
+ build_dir = firmware_build_dir(root)
1109
+ desc_path = File.join(build_dir, "project_description.json")
1110
+ flash_args_path = File.join(build_dir, "flash_args")
1111
+ json_error(404, "not built yet") unless File.file?(desc_path) && File.file?(flash_args_path)
1112
+
1113
+ target = JSON.parse(File.read(desc_path))["target"]
1114
+ chip_family = CHIP_FAMILY_MAP[target]
1115
+ json_error(500, "unsupported target: #{target}") unless chip_family
1116
+
1117
+ parts = File.readlines(flash_args_path).drop(1).filter_map do |line|
1118
+ line = line.strip
1119
+ next if line.empty?
1120
+
1121
+ offset_hex, rel_path = line.split(" ", 2)
1122
+ # rel_path は "bootloader/bootloader.bin" のようにサブディレクトリを含むことがある。
1123
+ # basename に切り詰めると実体(build/bootloader/bootloader.bin)と食い違って
1124
+ # 404になるため、相対パスのままURLに使う(下の配信ルート側もそれに合わせてある)。
1125
+ { path: "/api/firmware/#{rel_path}", offset: Integer(offset_hex, 16) }
1126
+ end
1127
+
1128
+ {
1129
+ name: "R2P2-ESP32",
1130
+ version: Time.now.strftime("%Y%m%d%H%M%S"),
1131
+ builds: [{ chipFamily: chip_family, parts: parts }]
1132
+ }.to_json
1133
+ end
1134
+
1135
+ # ビルド成果物の .bin ファイルを配信する。flash_args の相対パスをそのまま受け取る
1136
+ # (例: "bootloader/bootloader.bin" のようにサブディレクトリを含むことがある)ので、
1137
+ # 固定セグメントの :filename ではなくワイルドカードで受ける。
1138
+ # manifestのpartsが返すpathはここに合わせてある。
1139
+ get "/api/firmware/*" do
1140
+ rel_path = params[:splat].first
1141
+ json_error(400, "invalid filename") unless rel_path.end_with?(".bin")
1142
+
1143
+ full =
1144
+ begin
1145
+ safe_path(firmware_build_dir(project_root), rel_path)
1146
+ rescue ArgumentError
1147
+ json_error(400, "invalid path")
1148
+ end
1149
+
1150
+ json_error(404, "file not found") unless File.file?(full)
1151
+
1152
+ content_type "application/octet-stream"
1153
+ File.read(full, mode: "rb")
1154
+ end
1155
+ end
1156
+ end