ruby_everywhere 0.4.0 → 0.5.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.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +86 -2
  3. data/bridge/README.md +70 -1
  4. data/bridge/everywhere/bridge.js +151 -0
  5. data/bridge/everywhere/native.css +61 -0
  6. data/lib/everywhere/auth_handoff.rb +8 -2
  7. data/lib/everywhere/builders/desktop.rb +326 -0
  8. data/lib/everywhere/child_processes.rb +74 -0
  9. data/lib/everywhere/commands/build.rb +44 -5
  10. data/lib/everywhere/commands/dev.rb +426 -59
  11. data/lib/everywhere/commands/install.rb +20 -0
  12. data/lib/everywhere/commands/release.rb +2 -2
  13. data/lib/everywhere/config.rb +298 -4
  14. data/lib/everywhere/console.rb +117 -0
  15. data/lib/everywhere/desktop_assets.rb +150 -0
  16. data/lib/everywhere/dock/footer.rb +150 -0
  17. data/lib/everywhere/dock/screen.rb +114 -0
  18. data/lib/everywhere/dock/state.rb +59 -0
  19. data/lib/everywhere/dock.rb +238 -0
  20. data/lib/everywhere/emulator.rb +2 -2
  21. data/lib/everywhere/fatal.rb +20 -0
  22. data/lib/everywhere/line_pump.rb +89 -0
  23. data/lib/everywhere/log_filter.rb +37 -0
  24. data/lib/everywhere/native_helper.rb +38 -5
  25. data/lib/everywhere/paths.rb +62 -11
  26. data/lib/everywhere/relay.rb +77 -0
  27. data/lib/everywhere/shellout.rb +90 -15
  28. data/lib/everywhere/task_pool.rb +123 -0
  29. data/lib/everywhere/ui.rb +54 -11
  30. data/lib/everywhere/version.rb +1 -1
  31. data/support/desktop/README.md +121 -0
  32. data/support/{shell → desktop}/src-tauri/Cargo.lock +23 -0
  33. data/support/{shell → desktop}/src-tauri/Cargo.toml +19 -1
  34. data/support/{shell → desktop}/src-tauri/capabilities/default.json +7 -0
  35. data/support/desktop/src-tauri/gen/schemas/capabilities.json +1 -0
  36. data/support/desktop/src-tauri/src/extension_host.rs +53 -0
  37. data/support/desktop/src-tauri/src/extensions/mod.rs +39 -0
  38. data/support/{shell → desktop}/src-tauri/src/main.rs +355 -10
  39. data/support/{shell → desktop}/src-tauri/tauri.conf.json +2 -2
  40. data/support/{macos → release/macos}/notarize.sh +2 -2
  41. metadata +31 -17
  42. data/support/github/build.yml +0 -85
  43. data/support/shell/src-tauri/gen/schemas/capabilities.json +0 -1
  44. /data/support/{shell → desktop}/splash/index.html +0 -0
  45. /data/support/{shell → desktop}/src-tauri/build.rs +0 -0
  46. /data/support/{shell → desktop}/src-tauri/gen/schemas/acl-manifests.json +0 -0
  47. /data/support/{shell → desktop}/src-tauri/gen/schemas/desktop-schema.json +0 -0
  48. /data/support/{shell → desktop}/src-tauri/gen/schemas/macOS-schema.json +0 -0
  49. /data/support/{shell → desktop}/src-tauri/icons/icon.png +0 -0
  50. /data/support/{shell → desktop}/src-tauri/src/updater.rs +0 -0
  51. /data/support/{macos → release/macos}/entitlements.plist +0 -0
@@ -0,0 +1,326 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require_relative "../paths"
5
+ require_relative "../ui"
6
+ require_relative "../version"
7
+
8
+ module Everywhere
9
+ module Builders
10
+ # Resolves where cargo should run for the desktop shell, and stamps a
11
+ # per-app copy of it when the app declares `native.desktop`.
12
+ #
13
+ # Two paths, deliberately:
14
+ #
15
+ # no native.desktop the gem's own support/desktop/src-tauri, compiled
16
+ # against the SHARED ~/.rubyeverywhere/shell-target.
17
+ # Identical source for every app, so one warm target
18
+ # dir serves all of them. This is what every app did
19
+ # before extensions existed, and it stays free.
20
+ #
21
+ # native.desktop a stamped copy under ~/.rubyeverywhere/desktop/<id>
22
+ # with its own target dir, exactly like the iOS and
23
+ # Android work dirs. Costs one cold Tauri compile
24
+ # (minutes) and a couple of GB, which is why it is not
25
+ # the default.
26
+ #
27
+ # The stamped list, and nothing else:
28
+ #
29
+ # Cargo.toml the template's, with native.desktop.crates
30
+ # substituted into the __EVERYWHERE_APP_CRATES__
31
+ # marker
32
+ # src/extensions/** native/desktop/**/*.rs, plus a generated
33
+ # mod.rs per directory
34
+ #
35
+ # Everything else in the work dir is the frozen template, copied as-is. In
36
+ # particular src/main.rs, src/extension_host.rs and capabilities/ are never
37
+ # rewritten — see support/desktop/README.md.
38
+ class Desktop
39
+ # The generated module file, at every level of src/extensions. Reserved:
40
+ # an app shipping its own would be silently overwritten, so we refuse.
41
+ MODULE_FILE = "mod.rs"
42
+ STAGE_MARKER = ".everywhere-template"
43
+ CRATES_MARKER = "# __EVERYWHERE_APP_CRATES__"
44
+
45
+ # Where the app keeps its Rust, relative to the app root.
46
+ SOURCE_DIR = File.join("native", "desktop")
47
+
48
+ Prepared = Struct.new(:dir, :target_dir, :stamped, keyword_init: true) do
49
+ def stamped? = stamped
50
+ end
51
+
52
+ def initialize(config:, root:, template_dir: nil)
53
+ @config = config
54
+ @root = root
55
+ @template_dir = template_dir
56
+ end
57
+
58
+ # Returns where to run cargo and what to set CARGO_TARGET_DIR to. Callers
59
+ # (`every dev`, `every build`) don't need to care which path they got.
60
+ def prepare!
61
+ template = @template_dir || Paths.shell_dir!
62
+ unless @config.native_desktop?
63
+ return Prepared.new(dir: template, target_dir: Paths.cargo_target_dir, stamped: false)
64
+ end
65
+
66
+ preflight!
67
+ src_tauri = stage(template)
68
+ stamp!(src_tauri)
69
+ Prepared.new(dir: src_tauri, target_dir: Paths.desktop_target_dir(bundle_id), stamped: true)
70
+ end
71
+
72
+ private
73
+
74
+ def bundle_id = @config.bundle_id
75
+
76
+ # Config errors first: they're free to check, and everything below either
77
+ # interpolates these values into generated Rust or spends minutes on cargo.
78
+ def preflight!
79
+ errors = @config.native_desktop_errors
80
+ return if errors.empty?
81
+
82
+ UI.die!("native.desktop in config/everywhere.yml:\n#{errors.join("\n")}")
83
+ end
84
+
85
+ # Merge-copy the template over the work dir, NOT a wipe like the iOS
86
+ # stage: cargo's incremental state (target/ is elsewhere, but .fingerprint
87
+ # data and the lock file are not) plus a resolved Cargo.lock live here, and
88
+ # a cold Tauri build is minutes against a warm one's seconds. The one
89
+ # directory the stamp regenerates wholesale (src/extensions/) is cleared by
90
+ # its own writer, so deleted app files stop compiling.
91
+ #
92
+ # A gem upgrade wipes it: the template's own sources changed, and merging
93
+ # new files over old ones would leave whatever the new template deleted.
94
+ # `template` points at src-tauri (that's what --shell-dir means), but the
95
+ # whole shell root has to come along: tauri.conf.json's frontendDist is
96
+ # "../splash", a sibling of src-tauri, and generate_context! panics at
97
+ # compile time if it isn't there. So we copy the parent and hand back the
98
+ # src-tauri inside it.
99
+ def stage(template)
100
+ work = Paths.desktop_work_dir(bundle_id)
101
+ FileUtils.rm_rf(work) unless staged_version(work) == Everywhere::VERSION
102
+ FileUtils.mkdir_p(work)
103
+ FileUtils.cp_r(File.join(File.dirname(template), "."), work)
104
+ File.write(File.join(work, STAGE_MARKER), "#{Everywhere::VERSION}\n")
105
+ File.join(work, File.basename(template))
106
+ end
107
+
108
+ def staged_version(work)
109
+ marker = File.join(work, STAGE_MARKER)
110
+ File.read(marker).strip if File.file?(marker)
111
+ end
112
+
113
+ def stamp!(work)
114
+ write_cargo_toml(work)
115
+ write_extensions(work)
116
+ end
117
+
118
+ # Cargo has no include mechanism, so the crate list can't be its own file
119
+ # the way Android's native-packages.gradle.kts is — the template carries a
120
+ # marker comment as the last line of [dependencies] and we replace it here.
121
+ # In the STAMPED copy: the gem's Cargo.toml is never touched.
122
+ def write_cargo_toml(work)
123
+ path = File.join(work, "Cargo.toml")
124
+ source = File.read(path)
125
+ unless source.include?(CRATES_MARKER)
126
+ UI.die!("the shell's Cargo.toml has no #{CRATES_MARKER} marker — " \
127
+ "reinstall ruby_everywhere or pass --shell-dir at your own risk")
128
+ end
129
+
130
+ crates = @config.native_desktop_crates
131
+ UI.step("desktop crates: #{crates.keys.join(", ")}") unless crates.empty?
132
+ File.write(path, source.sub(CRATES_MARKER, render_crates(crates)))
133
+ end
134
+
135
+ def render_crates(crates)
136
+ return "# (no native.desktop.crates declared)" if crates.empty?
137
+
138
+ lines = ["# from native.desktop.crates in config/everywhere.yml"]
139
+ lines += crates.map { |name, spec| "#{name} = #{render_spec(spec)}" }
140
+ lines.join("\n")
141
+ end
142
+
143
+ # A lone version renders as cargo's short form; anything else as an inline
144
+ # table. Key order is fixed so a rebuild with unchanged config produces a
145
+ # byte-identical Cargo.toml — cargo re-resolves on any change to this file.
146
+ TABLE_KEYS = %w[version git branch tag rev path features default_features optional].freeze
147
+
148
+ def render_spec(spec)
149
+ return toml_string(spec["version"]) if spec.keys == ["version"]
150
+
151
+ pairs = TABLE_KEYS.filter_map do |key|
152
+ value = spec[key]
153
+ next if value.nil? || (value.respond_to?(:empty?) && value.empty?)
154
+
155
+ # default_features is the only key whose YAML spelling differs from
156
+ # cargo's; everything else is verbatim.
157
+ "#{key.tr("_", "-")} = #{toml_value(value)}"
158
+ end
159
+ "{ #{pairs.join(", ")} }"
160
+ end
161
+
162
+ def toml_value(value)
163
+ case value
164
+ when true, false then value.to_s
165
+ when Array then "[#{value.map { |item| toml_string(item) }.join(", ")}]"
166
+ else toml_string(value)
167
+ end
168
+ end
169
+
170
+ # Config validation already rejects quotes, backslashes and newlines in
171
+ # every string that reaches here; escaping anyway is belt-and-braces, so a
172
+ # future key added to TABLE_KEYS can't become an injection by omission.
173
+ def toml_string(value)
174
+ %("#{value.to_s.gsub("\\", "\\\\\\\\").gsub('"', '\"')}")
175
+ end
176
+
177
+ # native/desktop/**/*.rs → src/extensions/, preserving relative paths, plus
178
+ # a generated mod.rs per directory. Rust needs a module declaration for
179
+ # every file, so the directory tree IS the module tree.
180
+ def write_extensions(work)
181
+ dest = File.join(work, "src", "extensions")
182
+ # Regenerated wholesale: a file deleted from the app repo has to stop
183
+ # compiling, and a stale module declaration would fail the build in a
184
+ # way that points at the wrong place.
185
+ FileUtils.rm_rf(dest)
186
+ FileUtils.mkdir_p(dest)
187
+
188
+ source = File.join(@root, SOURCE_DIR)
189
+ sources = File.directory?(source) ? Dir.glob(File.join(source, "**", "*.rs")).sort : []
190
+ sources.each do |path|
191
+ relative = path.delete_prefix("#{source}/")
192
+ if File.basename(relative) == MODULE_FILE
193
+ UI.die!("#{File.join(SOURCE_DIR, relative)} is generated by `every build` — " \
194
+ "remove it and let the CLI write the module declarations")
195
+ end
196
+
197
+ target = File.join(dest, relative)
198
+ FileUtils.mkdir_p(File.dirname(target))
199
+ FileUtils.cp(path, target)
200
+ end
201
+
202
+ modules = module_tree(sources.map { |path| path.delete_prefix("#{source}/") })
203
+ write_module_files(dest, modules)
204
+ report(sources, modules)
205
+ end
206
+
207
+ # Directory → the child modules declared in its mod.rs. "" is the root.
208
+ # A .rs file contributes its basename; a sub-directory contributes itself
209
+ # and gets a mod.rs of its own.
210
+ def module_tree(relatives)
211
+ tree = Hash.new { |hash, key| hash[key] = [] }
212
+ tree[""] # the root always exists, even with no sources
213
+ relatives.each do |relative|
214
+ parts = relative.delete_suffix(".rs").split(File::SEPARATOR)
215
+ parts.each_with_index do |part, index|
216
+ parent = parts[0...index].join(File::SEPARATOR)
217
+ tree[parent] << part unless tree[parent].include?(part)
218
+ end
219
+ end
220
+ tree.transform_values(&:sort)
221
+ end
222
+
223
+ def write_module_files(dest, modules)
224
+ modules.each do |dir, children|
225
+ path = dir.empty? ? File.join(dest, MODULE_FILE) : File.join(dest, dir, MODULE_FILE)
226
+ FileUtils.mkdir_p(File.dirname(path))
227
+ File.write(path, dir.empty? ? root_module(modules) : child_module(children))
228
+ end
229
+ end
230
+
231
+ def child_module(children)
232
+ <<~RUST
233
+ // GENERATED by `every build` — edits are overwritten.
234
+ // Declares the .rs files this directory holds in native/desktop/.
235
+ #{children.map { |name| "pub mod #{name};" }.join("\n")}
236
+ RUST
237
+ end
238
+
239
+ # Everything reachable from the root, as `self::a::b` paths, so the
240
+ # dispatcher can glob-import them all.
241
+ def module_paths(modules)
242
+ modules.flat_map do |dir, children|
243
+ prefix = dir.empty? ? [] : dir.split(File::SEPARATOR)
244
+ children.map { |name| (prefix + [name]).join("::") }
245
+ end.sort
246
+ end
247
+
248
+ def root_module(modules)
249
+ commands = @config.native_desktop_commands
250
+ paths = module_paths(modules)
251
+
252
+ <<~RUST
253
+ // GENERATED by `every build` / `every dev` from `native.desktop` in
254
+ // config/everywhere.yml, alongside the app's own .rs files copied from
255
+ // native/desktop/. Edits are overwritten — see support/desktop/README.md.
256
+
257
+ use crate::commands;
258
+
259
+ #{modules.fetch("", []).map { |name| "pub mod #{name};" }.join("\n")}
260
+
261
+ // Command names are global, the same way iOS screen types and asset
262
+ // names are: you declare `scan_ports`, not `serial::scan_ports`. Two
263
+ // modules defining the same name is a compile error naming both, which
264
+ // is the right way to find out.
265
+ #{glob_imports(paths)}
266
+
267
+ // The shell's own commands. App extensions answer over events instead —
268
+ // see extension_host.rs for why invoke isn't available to them.
269
+ pub fn handler() -> impl Fn(tauri::ipc::Invoke<tauri::Wry>) -> bool + Send + Sync + 'static {
270
+ tauri::generate_handler![commands::notify_command]
271
+ }
272
+
273
+ pub fn setup(app: &tauri::App) -> Result<(), Box<dyn std::error::Error>> {
274
+ crate::extension_host::install_command_bridge(app, dispatch);
275
+ #{setup_call} Ok(())
276
+ }
277
+
278
+ fn dispatch(
279
+ app: &tauri::AppHandle,
280
+ name: &str,
281
+ payload: serde_json::Value,
282
+ ) -> Result<serde_json::Value, String> {
283
+ match name {
284
+ #{dispatch_arms(commands)} _ => Err(format!(
285
+ "no desktop command named {name:?} — declare it under native.desktop.commands"
286
+ )),
287
+ }
288
+ }
289
+ RUST
290
+ end
291
+
292
+ # One attribute per import, not one for the block: #[allow] applies to the
293
+ # item that follows it, so a single leading attribute would leave every
294
+ # import after the first warning. Most of these ARE unused — a module of
295
+ # helpers with no command in it is normal.
296
+ def glob_imports(paths)
297
+ paths.map { |path| "#[allow(unused_imports)]\nuse self::#{path}::*;" }.join("\n")
298
+ end
299
+
300
+ # `setup: true` means the app defines setup() in native/desktop/setup.rs.
301
+ # One fixed location rather than a configurable one: it's called exactly
302
+ # once from exactly here, so a second way to spell it buys nothing.
303
+ def setup_call
304
+ return "" unless @config.native_desktop_setup?
305
+
306
+ " setup::setup(app)?;\n"
307
+ end
308
+
309
+ def dispatch_arms(commands)
310
+ commands.map { |name| " #{name.inspect} => #{name}(app, payload),\n" }.join
311
+ end
312
+
313
+ def report(sources, modules)
314
+ commands = @config.native_desktop_commands
315
+ if sources.empty? && !commands.empty?
316
+ UI.warn("native.desktop declares #{commands.join(", ")} but #{SOURCE_DIR}/ has no .rs files")
317
+ return
318
+ end
319
+ return if sources.empty?
320
+
321
+ UI.step("desktop extensions: #{sources.length} file#{"s" unless sources.length == 1} " \
322
+ "(#{modules.fetch("", []).join(", ")})#{" → #{commands.join(", ")}" unless commands.empty?}")
323
+ end
324
+ end
325
+ end
326
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Everywhere
4
+ # A thread-scoped registry of the long-running children a task spawned, so the
5
+ # task can be cancelled without hunting pids by hand.
6
+ #
7
+ # `every dev` runs each build in its own thread. When the session ends (or a
8
+ # build has to be abandoned) we need to kill *that* thread's xcodebuild or
9
+ # gradlew and nothing else — a global list would leak entries from threads
10
+ # that died unexpectedly and would make "which build does this pid belong to?"
11
+ # unanswerable. Registering against the spawning thread answers it for free.
12
+ #
13
+ # Only genuinely long-running children register (see Shellout.run_logged!).
14
+ # Short probes — simctl, adb, xcrun — deliberately stay in the CLI's own
15
+ # process group, where a Ctrl-C at the terminal kills them as it always has.
16
+ module ChildProcesses
17
+ LOCK = Mutex.new
18
+ PIDS = :everywhere_child_pids
19
+ ISOLATE = :everywhere_isolate_children
20
+
21
+ module_function
22
+
23
+ # Mark this thread's long-running children as needing their own process
24
+ # group — set by TaskPool for every worker. Outside a worker this is false
25
+ # and `every build` / `every release` keep today's behavior, where a Ctrl-C
26
+ # at the terminal reaches the compiler directly.
27
+ def isolate! = Thread.current.thread_variable_set(ISOLATE, true)
28
+
29
+ def isolated? = !!Thread.current.thread_variable_get(ISOLATE)
30
+
31
+ def track(pid) = LOCK.synchronize { list << pid }
32
+ def untrack(pid) = LOCK.synchronize { list.delete(pid) }
33
+
34
+ # TERM every child `thread` spawned, escalating to KILL for anything still
35
+ # alive after `grace`. Returns the pids that had to be killed.
36
+ def terminate(thread, grace: 5)
37
+ pids = LOCK.synchronize { (thread.thread_variable_get(PIDS) || []).dup }
38
+ return [] if pids.empty?
39
+
40
+ pids.each { |pid| signal(pid, "TERM") }
41
+ deadline = now + grace
42
+ sleep(0.1) while pids.any? { |pid| alive?(pid) } && now < deadline
43
+ pids.select { |pid| alive?(pid) }.each { |pid| signal(pid, "KILL") }
44
+ end
45
+
46
+ # Signal a child's whole process group, falling back to the bare pid when it
47
+ # doesn't lead one. Never raises: callers are teardown paths.
48
+ def signal(pid, sig)
49
+ Process.kill(sig, -Process.getpgid(pid))
50
+ rescue StandardError
51
+ begin
52
+ Process.kill(sig, pid)
53
+ rescue StandardError
54
+ nil
55
+ end
56
+ end
57
+
58
+ def alive?(pid)
59
+ Process.kill(0, pid)
60
+ true
61
+ rescue Errno::ESRCH
62
+ false
63
+ rescue Errno::EPERM
64
+ true
65
+ end
66
+
67
+ def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
68
+
69
+ def list
70
+ Thread.current.thread_variable_get(PIDS) ||
71
+ Thread.current.thread_variable_set(PIDS, [])
72
+ end
73
+ end
74
+ end
@@ -9,8 +9,10 @@ require_relative "../log_filter"
9
9
  require_relative "../png"
10
10
  require_relative "../icon"
11
11
  require_relative "../paths"
12
+ require_relative "../desktop_assets"
12
13
  require_relative "../builders/ios"
13
14
  require_relative "../builders/android"
15
+ require_relative "../builders/desktop"
14
16
 
15
17
  module Everywhere
16
18
  module Commands
@@ -68,6 +70,16 @@ module Everywhere
68
70
  UI.die!("no builder for target #{target} — macos-*, ios-* and android-* are supported")
69
71
  end
70
72
 
73
+ # Desktop chrome is validated here rather than in the shell: a typo in
74
+ # `window:` would otherwise fall through Rust's match arms as a silent
75
+ # default, and you'd learn about it by squinting at a title bar after a
76
+ # full press+compile. The mobile builders validate their own sections
77
+ # the same way (Builders::Ios#write_extensions).
78
+ window_errors = config.window_errors
79
+ unless window_errors.empty?
80
+ UI.die!("window: in config/everywhere.yml:\n#{window_errors.join("\n")}")
81
+ end
82
+
71
83
  # The os of `target` selects any per-platform overrides (bundle id,
72
84
  # version, name) — local builds always target the host, macOS today.
73
85
  app_name ||= config.name(target: target)
@@ -153,7 +165,7 @@ module Everywhere
153
165
  # EVERY_BUNDLE_PATH (set by `every platform runner`) redirects the app's
154
166
  # gems to a stable per-user dir so CI can cache them across builds. It
155
167
  # must be merged EXPLICITLY into each host-side bundler command:
156
- # Shellout.unbundled strips inherited BUNDLE_* vars, so an inherited
168
+ # Shellout.child_env strips inherited BUNDLE_* vars, so an inherited
157
169
  # BUNDLE_PATH would never survive to the child process.
158
170
  def bundle_env
159
171
  path = ENV["EVERY_BUNDLE_PATH"]
@@ -204,7 +216,12 @@ module Everywhere
204
216
  # Rails binary (as "app-server") + Info.plist + icon. An .app bundle is
205
217
  # just a directory layout, so we build it by hand — no tauri-cli needed.
206
218
  def bundle_app(server_binary, app_name, shell_dir, config, target: "macos-arm64", dist_dir: nil)
207
- shell_dir ||= Everywhere::Paths.shell_dir!
219
+ # An app with native.desktop gets a stamped per-app shell; everyone else
220
+ # compiles the gem's copy against the shared target dir, exactly as
221
+ # before. Builders::Desktop decides which, and the rest of this method
222
+ # doesn't need to know.
223
+ prepared = Builders::Desktop.new(config: config, root: config.root, template_dir: shell_dir).prepare!
224
+ shell_dir = prepared.dir
208
225
  dist_dir ||= File.dirname(server_binary)
209
226
 
210
227
  icon_source = config.icon
@@ -235,6 +252,12 @@ module Everywhere
235
252
  # shell must be COMPILED with the app's icon. Swap in the shaped master
236
253
  # for the build, restore the placeholder after; touch build.rs so
237
254
  # tauri-build re-embeds.
255
+ #
256
+ # The restore only matters on the fast path, where shell_dir is the
257
+ # INSTALLED GEM and every app shares it — leaving one app's icon there
258
+ # would brand the next app's build. A stamped work dir belongs to this
259
+ # app alone, so its icon can stay: restoring would only force another
260
+ # re-embed on the next build.
238
261
  shell_icon = File.join(shell_dir, "icons", "icon.png")
239
262
  placeholder_backup = "#{shell_icon}.placeholder"
240
263
  if icon_source
@@ -254,12 +277,12 @@ module Everywhere
254
277
  FileUtils.touch(File.join(shell_dir, "build.rs"))
255
278
  end
256
279
 
257
- UI.step("building shell #{UI.dim("(cargo release)")}")
258
- target_dir = Everywhere::Paths.cargo_target_dir
280
+ UI.step("building shell #{UI.dim(prepared.stamped? ? "(cargo release, per-app)" : "(cargo release)")}")
281
+ target_dir = prepared.target_dir
259
282
  begin
260
283
  Shellout.run!({ "CARGO_TARGET_DIR" => target_dir }, "cargo", "build", "--release", chdir: shell_dir)
261
284
  ensure
262
- if icon_source && File.exist?(placeholder_backup)
285
+ if !prepared.stamped? && icon_source && File.exist?(placeholder_backup)
263
286
  FileUtils.mv(placeholder_backup, shell_icon)
264
287
  FileUtils.touch(File.join(shell_dir, "build.rs"))
265
288
  end
@@ -280,6 +303,7 @@ module Everywhere
280
303
  FileUtils.cp(server_binary, File.join(macos, "app-server")) if server_binary
281
304
 
282
305
  File.write(File.join(resources, "everywhere.json"), config.to_shell_json(target: target))
306
+ write_desktop_assets(config, resources)
283
307
  if (splash = config.splash)
284
308
  if File.exist?(splash)
285
309
  FileUtils.cp(splash, File.join(resources, "splash.html"))
@@ -306,6 +330,21 @@ module Everywhere
306
330
  FileUtils.remove_entry(work) if work && File.directory?(work)
307
331
  end
308
332
 
333
+ # native/desktop/assets/** → Contents/Resources/assets/, where the shell
334
+ # finds the tray icon and extension Rust finds anything it draws. The
335
+ # mobile builders compile their assets into a platform resource format;
336
+ # desktop has none, so this is a validated copy (Everywhere::DesktopAssets).
337
+ def write_desktop_assets(config, resources)
338
+ source = File.join(config.root, "native", "desktop", "assets")
339
+ names, errors = Everywhere::DesktopAssets.write!(source, File.join(resources, "assets"))
340
+ unless errors.empty?
341
+ UI.die!("native/desktop/assets:\n#{errors.join("\n")}")
342
+ end
343
+ return if names.empty?
344
+
345
+ UI.step("desktop assets: #{names.join(", ")}")
346
+ end
347
+
309
348
  # Write Resources/AppIcon.icns from a (shaped) PNG. Pure Ruby — no sips or
310
349
  # iconutil — so it produces the same icns on any build host.
311
350
  def make_icns(png, resources_dir)