ruby_everywhere 0.3.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 (125) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +339 -0
  3. data/bridge/README.md +70 -1
  4. data/bridge/everywhere/bridge.js +211 -3
  5. data/bridge/everywhere/native.css +61 -0
  6. data/lib/everywhere/android_resources.rb +217 -0
  7. data/lib/everywhere/android_sdk.rb +330 -0
  8. data/lib/everywhere/asset_catalog.rb +185 -0
  9. data/lib/everywhere/auth_handoff.rb +404 -0
  10. data/lib/everywhere/auth_token.rb +118 -0
  11. data/lib/everywhere/builders/android.rb +844 -0
  12. data/lib/everywhere/builders/desktop.rb +326 -0
  13. data/lib/everywhere/builders/ios.rb +40 -3
  14. data/lib/everywhere/child_processes.rb +74 -0
  15. data/lib/everywhere/commands/build.rb +95 -14
  16. data/lib/everywhere/commands/clean.rb +10 -5
  17. data/lib/everywhere/commands/dev.rb +462 -57
  18. data/lib/everywhere/commands/doctor.rb +89 -3
  19. data/lib/everywhere/commands/install.rb +21 -1
  20. data/lib/everywhere/commands/logs.rb +117 -8
  21. data/lib/everywhere/commands/release.rb +2 -2
  22. data/lib/everywhere/config.rb +703 -13
  23. data/lib/everywhere/console.rb +117 -0
  24. data/lib/everywhere/desktop_assets.rb +150 -0
  25. data/lib/everywhere/dock/footer.rb +150 -0
  26. data/lib/everywhere/dock/screen.rb +114 -0
  27. data/lib/everywhere/dock/state.rb +59 -0
  28. data/lib/everywhere/dock.rb +238 -0
  29. data/lib/everywhere/emulator.rb +405 -0
  30. data/lib/everywhere/engine.rb +12 -0
  31. data/lib/everywhere/fatal.rb +20 -0
  32. data/lib/everywhere/icon.rb +196 -0
  33. data/lib/everywhere/line_pump.rb +89 -0
  34. data/lib/everywhere/log_filter.rb +111 -0
  35. data/lib/everywhere/native_helper.rb +39 -9
  36. data/lib/everywhere/omniauth.rb +88 -0
  37. data/lib/everywhere/paths.rb +109 -11
  38. data/lib/everywhere/relay.rb +77 -0
  39. data/lib/everywhere/shellout.rb +90 -15
  40. data/lib/everywhere/task_pool.rb +123 -0
  41. data/lib/everywhere/ui.rb +54 -11
  42. data/lib/everywhere/version.rb +1 -1
  43. data/support/desktop/README.md +121 -0
  44. data/support/{shell → desktop}/src-tauri/Cargo.lock +23 -0
  45. data/support/{shell → desktop}/src-tauri/Cargo.toml +19 -1
  46. data/support/{shell → desktop}/src-tauri/capabilities/default.json +7 -0
  47. data/support/desktop/src-tauri/gen/schemas/capabilities.json +1 -0
  48. data/support/desktop/src-tauri/src/extension_host.rs +53 -0
  49. data/support/desktop/src-tauri/src/extensions/mod.rs +39 -0
  50. data/support/{shell → desktop}/src-tauri/src/main.rs +355 -10
  51. data/support/{shell → desktop}/src-tauri/tauri.conf.json +2 -2
  52. data/support/mobile/android/README.md +217 -0
  53. data/support/mobile/android/app/build.gradle.kts +156 -0
  54. data/support/mobile/android/app/everywhere.properties +8 -0
  55. data/support/mobile/android/app/native-packages.gradle.kts +16 -0
  56. data/support/mobile/android/app/proguard-rules.pro +21 -0
  57. data/support/mobile/android/app/src/debug/res/xml/network_security_config.xml +30 -0
  58. data/support/mobile/android/app/src/main/AndroidManifest.xml +42 -0
  59. data/support/mobile/android/app/src/main/assets/everywhere.json +9 -0
  60. data/support/mobile/android/app/src/main/assets/fonts/MaterialIcons-Regular.codepoints +2235 -0
  61. data/support/mobile/android/app/src/main/assets/fonts/MaterialIcons-Regular.ttf +0 -0
  62. data/support/mobile/android/app/src/main/assets/json/path-configuration.json +19 -0
  63. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/AuthFlow.kt +268 -0
  64. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereApplication.kt +178 -0
  65. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereConfig.kt +409 -0
  66. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereEvents.kt +68 -0
  67. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereHost.kt +101 -0
  68. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/IconFont.kt +211 -0
  69. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/MainActivity.kt +975 -0
  70. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/MainTabs.kt +201 -0
  71. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/WebBottomSheetFragment.kt +13 -0
  72. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/WebFragment.kt +36 -0
  73. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/BiometricsComponent.kt +535 -0
  74. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/HapticsComponent.kt +141 -0
  75. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/MenuComponent.kt +361 -0
  76. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/NotificationComponent.kt +151 -0
  77. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/PermissionsComponent.kt +271 -0
  78. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/StorageComponent.kt +101 -0
  79. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/WebControlChannel.kt +204 -0
  80. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/extensions/EverywhereExtensions.kt +51 -0
  81. data/support/mobile/android/app/src/main/res/color/bottom_navigation_active_indicator.xml +4 -0
  82. data/support/mobile/android/app/src/main/res/color/bottom_navigation_item.xml +11 -0
  83. data/support/mobile/android/app/src/main/res/drawable/ic_launcher_foreground.xml +26 -0
  84. data/support/mobile/android/app/src/main/res/drawable/ic_tab_placeholder.xml +28 -0
  85. data/support/mobile/android/app/src/main/res/layout/activity_main.xml +116 -0
  86. data/support/mobile/android/app/src/main/res/layout/bridge_menu_sheet.xml +83 -0
  87. data/support/mobile/android/app/src/main/res/layout/bridge_menu_sheet_item.xml +35 -0
  88. data/support/mobile/android/app/src/main/res/layout/tab_more_sheet.xml +48 -0
  89. data/support/mobile/android/app/src/main/res/layout/tab_more_sheet_item.xml +35 -0
  90. data/support/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +12 -0
  91. data/support/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +6 -0
  92. data/support/mobile/android/app/src/main/res/values/colors.xml +21 -0
  93. data/support/mobile/android/app/src/main/res/values/ic_launcher_background.xml +5 -0
  94. data/support/mobile/android/app/src/main/res/values/strings.xml +18 -0
  95. data/support/mobile/android/app/src/main/res/values/styles.xml +32 -0
  96. data/support/mobile/android/app/src/main/res/values/themes.xml +53 -0
  97. data/support/mobile/android/app/src/main/res/values-night/colors.xml +11 -0
  98. data/support/mobile/android/app/src/main/res/values-night/themes.xml +33 -0
  99. data/support/mobile/android/app/src/main/res/xml/network_security_config.xml +12 -0
  100. data/support/mobile/android/app/src/stamped/AndroidManifest.xml +34 -0
  101. data/support/mobile/android/build.gradle.kts +8 -0
  102. data/support/mobile/android/gradle/wrapper/gradle-wrapper.jar +0 -0
  103. data/support/mobile/android/gradle/wrapper/gradle-wrapper.properties +10 -0
  104. data/support/mobile/android/gradle.properties +14 -0
  105. data/support/mobile/android/gradlew +251 -0
  106. data/support/mobile/android/gradlew.bat +94 -0
  107. data/support/mobile/android/settings.gradle.kts +28 -0
  108. data/support/mobile/ios/App/AppDelegate.swift +5 -0
  109. data/support/mobile/ios/App/AuthFlow.swift +101 -0
  110. data/support/mobile/ios/App/EverywhereConfig.swift +84 -0
  111. data/support/mobile/ios/App/SceneDelegate.swift +103 -0
  112. data/support/mobile/ios/App.xcodeproj/project.pbxproj +4 -0
  113. data/support/mobile/ios/README.md +4 -1
  114. data/support/{macos → release/macos}/notarize.sh +2 -2
  115. metadata +97 -17
  116. data/support/github/build.yml +0 -85
  117. data/support/shell/src-tauri/gen/schemas/capabilities.json +0 -1
  118. /data/support/{shell → desktop}/splash/index.html +0 -0
  119. /data/support/{shell → desktop}/src-tauri/build.rs +0 -0
  120. /data/support/{shell → desktop}/src-tauri/gen/schemas/acl-manifests.json +0 -0
  121. /data/support/{shell → desktop}/src-tauri/gen/schemas/desktop-schema.json +0 -0
  122. /data/support/{shell → desktop}/src-tauri/gen/schemas/macOS-schema.json +0 -0
  123. /data/support/{shell → desktop}/src-tauri/icons/icon.png +0 -0
  124. /data/support/{shell → desktop}/src-tauri/src/updater.rs +0 -0
  125. /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
@@ -5,6 +5,7 @@ require_relative "../paths"
5
5
  require_relative "../shellout"
6
6
  require_relative "../log_filter"
7
7
  require_relative "../icon"
8
+ require_relative "../asset_catalog"
8
9
  require_relative "../ui"
9
10
 
10
11
  module Everywhere
@@ -72,9 +73,9 @@ module Everywhere
72
73
  end
73
74
 
74
75
  # The whole per-app surface: one xcconfig (identity), two JSON resources
75
- # (runtime config + path configuration with baked tabs), one PNG (icon),
76
- # and the Extensions/ buildable folder (app-authored Swift + generated
77
- # registry). The pbxproj is never touched.
76
+ # (runtime config + path configuration with baked tabs), the asset catalog
77
+ # (icon + app images), and the Extensions/ buildable folder (app-authored
78
+ # Swift + generated registry). The pbxproj is never touched.
78
79
  def stamp!(work)
79
80
  UI.step("stamping shell #{UI.dim("(#{bundle_id} → #{UI.short_path(work)})")}")
80
81
  write_xcconfig(work)
@@ -86,8 +87,10 @@ module Everywhere
86
87
  @config.path_configuration_json("ios"))
87
88
  write_launch_background(work)
88
89
  write_usage_strings(work)
90
+ write_url_scheme(work)
89
91
  write_entitlements(work)
90
92
  write_app_icon(work)
93
+ write_asset_catalog(work)
91
94
  write_extensions(work)
92
95
  write_native_packages(work)
93
96
  end
@@ -291,6 +294,25 @@ module Everywhere
291
294
  end
292
295
  end
293
296
 
297
+ # The custom URL scheme third-party sign-in returns through
298
+ # (everywhere.yml auth:) → Info.plist. ASWebAuthenticationSession claims
299
+ # the callback for itself while it's running, so this registration isn't
300
+ # what makes the flow work — it's what stops another app from quietly
301
+ # owning the scheme, and what makes `xcrun simctl openurl` usable for
302
+ # testing the return leg by hand.
303
+ def write_url_scheme(work)
304
+ errors = @config.auth_errors
305
+ UI.die!("auth in config/everywhere.yml:\n#{errors.join("\n")}") unless errors.empty?
306
+ return unless @config.oauth?
307
+
308
+ require "json"
309
+ scheme = @config.auth_scheme
310
+ UI.step("stamping sign-in callback scheme #{UI.dim("(#{scheme}://)")}")
311
+ Shellout.run!({}, "plutil", "-replace", "CFBundleURLTypes", "-json",
312
+ JSON.generate([{ "CFBundleURLName" => bundle_id, "CFBundleURLSchemes" => [scheme] }]),
313
+ File.join(work, "App", "Info.plist"))
314
+ end
315
+
294
316
  # The launch-screen (and splash overlay) background — the storyboard's
295
317
  # LaunchBackground named color, stamped from appearance.background_color
296
318
  # so the launch frame matches the app instead of the template default.
@@ -357,6 +379,21 @@ module Everywhere
357
379
  UI.warn "couldn't read #{File.basename(source)} — shipping the placeholder icon"
358
380
  end
359
381
 
382
+ # App-supplied images: native/ios/assets/** → imagesets in the template's
383
+ # Assets.xcassets, so native/ios Swift code reaches them by filename with
384
+ # Image("logo") / UIImage(named: "logo").
385
+ def write_asset_catalog(work)
386
+ source = File.join(@root, "native", "ios", "assets")
387
+ return unless File.directory?(source)
388
+
389
+ names, errors = AssetCatalog.write!(source, File.join(work, "App", "Assets.xcassets"))
390
+ UI.die!("native/ios/assets:\n#{errors.join("\n")}") unless errors.empty?
391
+ return if names.empty?
392
+
393
+ listed = names.length > 6 ? "#{names.take(6).join(", ")}, …" : names.join(", ")
394
+ UI.step("bundling image assets #{UI.dim("(#{names.length} from native/ios/assets: #{listed})")}")
395
+ end
396
+
360
397
  def xcodebuild!(work, configuration, dist_dir)
361
398
  UI.step("building iOS shell #{UI.dim("(xcodebuild, #{configuration}, first run compiles HotwireNative)")}")
362
399
  FileUtils.mkdir_p(dist_dir)
@@ -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,7 +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"
14
+ require_relative "../builders/android"
15
+ require_relative "../builders/desktop"
13
16
 
14
17
  module Everywhere
15
18
  module Commands
@@ -24,34 +27,57 @@ module Everywhere
24
27
  class Build < Dry::CLI::Command
25
28
  SHIM = File.expand_path("~/.tebako-shims/clang++")
26
29
 
27
- desc "Package the app natively — desktop (tebako press + shell + .app) or iOS (--target ios-arm64)"
30
+ DEFAULT_TARGET = "macos-arm64"
31
+ IOS_TARGET = "ios-arm64"
32
+ # The APK/AAB is universal, so the arch half is nominal — it exists only
33
+ # so every target reads as os-arch.
34
+ ANDROID_TARGET = "android-arm64"
35
+
36
+ # The --ios/--android shorthands and the os they stand for, so adding a
37
+ # mobile platform is one entry rather than a third copy of resolve_target.
38
+ SHORTHAND_TARGETS = { ios: IOS_TARGET, android: ANDROID_TARGET }.freeze
39
+
40
+ desc "Package the app natively — desktop (tebako press + shell + .app), iOS (--ios) or Android (--android)"
28
41
 
29
42
  option :root, default: ".", desc: "App root directory"
30
43
  option :output, default: nil, desc: "Output binary path (default: dist/<app>)"
31
44
  option :ruby, default: "4.0.6", desc: "Ruby version to package"
32
45
  option :entry, default: "native_boot.rb", desc: "Entry point script relative to root"
33
46
  option :app_name, default: nil, desc: "Bundle name (default: capitalized app dir)"
34
- option :target, default: "macos-arm64", desc: "Build target (os-arch) — selects any platforms: overrides"
47
+ option :target, default: nil, desc: "Build target (os-arch) — selects any platforms: overrides, default: \"#{DEFAULT_TARGET}\""
35
48
  option :shell_dir, default: nil, desc: "Path to the shell's src-tauri directory"
49
+ option :ios, type: :boolean, default: false, desc: "Build the iOS shell (shorthand for --target #{IOS_TARGET})"
50
+ option :android, type: :boolean, default: false, desc: "Build the Android shell (shorthand for --target #{ANDROID_TARGET})"
36
51
  option :skip_press, type: :boolean, default: false, desc: "Reuse the existing pressed binary, only rebuild the .app"
37
52
 
38
53
  def call(root: ".", output: nil, ruby: "4.0.6", entry: "native_boot.rb",
39
- app_name: nil, target: "macos-arm64", shell_dir: nil, skip_press: false, **)
54
+ app_name: nil, target: nil, shell_dir: nil, ios: false, android: false, skip_press: false, **)
55
+ target = resolve_target(target, ios: ios, android: android)
40
56
  root_path = File.expand_path(root)
41
57
  config = Everywhere::Config.load(root_path)
42
58
 
43
59
  # Builder dispatch on the target's os (build-engine.md §5): macOS is
44
- # the tebako+tauri path below; iOS is a Hotwire Native shell built by
45
- # its own builder. Anything else has no builder yet.
60
+ # the tebako+tauri path below; the mobile targets are Hotwire Native
61
+ # shells, each built by its own builder. Anything else has no builder yet.
46
62
  case Everywhere::Config.os_of(target)
47
- when "ios"
48
- app = Builders::Ios.new(config: config, root: root_path, target: target)
49
- .build!(dist_dir: File.expand_path("dist", root_path))
63
+ when "ios", "android"
64
+ app = mobile_builder(target).new(config: config, root: root_path, target: target)
65
+ .build!(dist_dir: File.expand_path("dist", root_path))
50
66
  UI.success("built #{rel(app, root_path)}")
51
67
  return
52
68
  when "macos" # the rest of this method
53
69
  else
54
- UI.die!("no builder for target #{target} — macos-* and ios-* are supported")
70
+ UI.die!("no builder for target #{target} — macos-*, ios-* and android-* are supported")
71
+ end
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")}")
55
81
  end
56
82
 
57
83
  # The os of `target` selects any per-platform overrides (bundle id,
@@ -95,6 +121,34 @@ module Everywhere
95
121
 
96
122
  private
97
123
 
124
+ # `--ios` is the shorthand `every dev` already has; --target stays the
125
+ # general escape hatch. Both given is only an error when they disagree —
126
+ # `--ios --target ios-arm64` is redundant, not wrong.
127
+ def mobile_builder(target)
128
+ Everywhere::Config.os_of(target) == "android" ? Builders::Android : Builders::Ios
129
+ end
130
+
131
+ # --ios/--android are shorthand for their target, and disagreeing with an
132
+ # explicit --target is a mistake worth naming rather than silently
133
+ # resolving one way: both spellings were deliberate, so we can't know
134
+ # which one the user meant.
135
+ def resolve_target(target, ios:, android:)
136
+ asked = { ios: ios, android: android }.select { |_flag, given| given }.keys
137
+ if asked.length > 1
138
+ UI.die!("#{asked.map { |f| "--#{f}" }.join(" and ")} are separate builds — run one at a time")
139
+ end
140
+
141
+ flag = asked.first or return target || DEFAULT_TARGET
142
+ shorthand = SHORTHAND_TARGETS.fetch(flag)
143
+ return shorthand if target.nil?
144
+
145
+ unless Everywhere::Config.os_of(target) == flag.to_s
146
+ UI.die!("--#{flag} and --target #{target} disagree — drop one " \
147
+ "(--#{flag} means --target #{shorthand})")
148
+ end
149
+ target
150
+ end
151
+
98
152
  def rel(path, root_path) = UI.short_path(path.sub("#{root_path}/", "").sub("#{Dir.pwd}/", ""))
99
153
 
100
154
  # The precompile step runs against the HOST Ruby — unlike tebako press,
@@ -111,7 +165,7 @@ module Everywhere
111
165
  # EVERY_BUNDLE_PATH (set by `every platform runner`) redirects the app's
112
166
  # gems to a stable per-user dir so CI can cache them across builds. It
113
167
  # must be merged EXPLICITLY into each host-side bundler command:
114
- # Shellout.unbundled strips inherited BUNDLE_* vars, so an inherited
168
+ # Shellout.child_env strips inherited BUNDLE_* vars, so an inherited
115
169
  # BUNDLE_PATH would never survive to the child process.
116
170
  def bundle_env
117
171
  path = ENV["EVERY_BUNDLE_PATH"]
@@ -162,7 +216,12 @@ module Everywhere
162
216
  # Rails binary (as "app-server") + Info.plist + icon. An .app bundle is
163
217
  # just a directory layout, so we build it by hand — no tauri-cli needed.
164
218
  def bundle_app(server_binary, app_name, shell_dir, config, target: "macos-arm64", dist_dir: nil)
165
- 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
166
225
  dist_dir ||= File.dirname(server_binary)
167
226
 
168
227
  icon_source = config.icon
@@ -193,6 +252,12 @@ module Everywhere
193
252
  # shell must be COMPILED with the app's icon. Swap in the shaped master
194
253
  # for the build, restore the placeholder after; touch build.rs so
195
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.
196
261
  shell_icon = File.join(shell_dir, "icons", "icon.png")
197
262
  placeholder_backup = "#{shell_icon}.placeholder"
198
263
  if icon_source
@@ -212,12 +277,12 @@ module Everywhere
212
277
  FileUtils.touch(File.join(shell_dir, "build.rs"))
213
278
  end
214
279
 
215
- UI.step("building shell #{UI.dim("(cargo release)")}")
216
- 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
217
282
  begin
218
283
  Shellout.run!({ "CARGO_TARGET_DIR" => target_dir }, "cargo", "build", "--release", chdir: shell_dir)
219
284
  ensure
220
- if icon_source && File.exist?(placeholder_backup)
285
+ if !prepared.stamped? && icon_source && File.exist?(placeholder_backup)
221
286
  FileUtils.mv(placeholder_backup, shell_icon)
222
287
  FileUtils.touch(File.join(shell_dir, "build.rs"))
223
288
  end
@@ -238,6 +303,7 @@ module Everywhere
238
303
  FileUtils.cp(server_binary, File.join(macos, "app-server")) if server_binary
239
304
 
240
305
  File.write(File.join(resources, "everywhere.json"), config.to_shell_json(target: target))
306
+ write_desktop_assets(config, resources)
241
307
  if (splash = config.splash)
242
308
  if File.exist?(splash)
243
309
  FileUtils.cp(splash, File.join(resources, "splash.html"))
@@ -264,6 +330,21 @@ module Everywhere
264
330
  FileUtils.remove_entry(work) if work && File.directory?(work)
265
331
  end
266
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
+
267
348
  # Write Resources/AppIcon.icns from a (shaped) PNG. Pure Ruby — no sips or
268
349
  # iconutil — so it produces the same icns on any build host.
269
350
  def make_icns(png, resources_dir)