ruby_everywhere 0.6.0 → 0.7.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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/exe/every +12 -1
  3. data/exe/rbe +12 -1
  4. data/lib/everywhere/agents_guide.rb +3 -1
  5. data/lib/everywhere/blake2b.rb +17 -1
  6. data/lib/everywhere/boot.rb +21 -4
  7. data/lib/everywhere/builders/android.rb +144 -30
  8. data/lib/everywhere/builders/desktop.rb +25 -17
  9. data/lib/everywhere/builders/ios.rb +244 -9
  10. data/lib/everywhere/cli.rb +2 -0
  11. data/lib/everywhere/commands/build.rb +113 -58
  12. data/lib/everywhere/commands/clean.rb +8 -3
  13. data/lib/everywhere/commands/dev.rb +85 -11
  14. data/lib/everywhere/commands/doctor.rb +138 -21
  15. data/lib/everywhere/commands/install.rb +48 -8
  16. data/lib/everywhere/commands/platform/build.rb +136 -27
  17. data/lib/everywhere/commands/platform/login.rb +16 -2
  18. data/lib/everywhere/commands/platform/runner.rb +113 -23
  19. data/lib/everywhere/commands/preview.rb +359 -0
  20. data/lib/everywhere/commands/publish.rb +20 -2
  21. data/lib/everywhere/commands/release.rb +145 -28
  22. data/lib/everywhere/commands/shell_dir.rb +2 -2
  23. data/lib/everywhere/config.rb +88 -3
  24. data/lib/everywhere/console.rb +2 -1
  25. data/lib/everywhere/engine.rb +24 -0
  26. data/lib/everywhere/framework.rb +21 -4
  27. data/lib/everywhere/ignore.rb +3 -1
  28. data/lib/everywhere/jump.rb +121 -0
  29. data/lib/everywhere/mobile_config_endpoint.rb +47 -0
  30. data/lib/everywhere/mobile_configs_controller.rb +46 -0
  31. data/lib/everywhere/paths.rb +13 -6
  32. data/lib/everywhere/platform/client.rb +27 -6
  33. data/lib/everywhere/platform/credentials.rb +18 -8
  34. data/lib/everywhere/platform/snapshot.rb +10 -0
  35. data/lib/everywhere/receipt.rb +55 -10
  36. data/lib/everywhere/shellout.rb +19 -0
  37. data/lib/everywhere/simulator.rb +12 -1
  38. data/lib/everywhere/version.rb +1 -1
  39. data/support/mobile/android/app/build.gradle.kts +29 -1
  40. data/support/mobile/ios/App/EverywhereConfig.swift +17 -5
  41. data/support/mobile/ios/App/SceneDelegate.swift +75 -8
  42. data/support/mobile/ios/App.xcodeproj/project.pbxproj +2 -2
  43. data/support/mobile/ios/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +0 -1
  44. data/support/mobile/ios/README.md +13 -6
  45. metadata +17 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1c87aa5a2416d070a8047b790f28c7230882999b7df4f7f584256ee96852f066
4
- data.tar.gz: 6fa25612acf17e6d90b3302a4e7f93a8d5ded79fd8ffb39aec318b908df32bcf
3
+ metadata.gz: 83b4d5f0135bc80870fdacccaf5014ee478cbaa2bad08541871ab67a6485d3fc
4
+ data.tar.gz: dd86792cae35b098877012c42ebe0286ada230909a8588c9a4c933c7c8771137
5
5
  SHA512:
6
- metadata.gz: 69fb1bdb659b8b886d9b602e09e21d8016caf140ac401ee371d4b0eaab2e06f3b342fb7742e2684570b80e71768fb2c5ede34dca3f1d5b9015185b93b176ba59
7
- data.tar.gz: 450a804d3bf9765ea664e0728c66df31019e5633393680120ef9ec7ba40def7e69f163b9aaaf6b2d2fcec948e62c5d3821b26bb553924666dfb57b7d1c6b29cd
6
+ metadata.gz: 97ad2a4b22e6255abcf6e68850ef9c2615ca725bfac46f62db9d7f2423314afdd882088589670cd609e3f9a8da854616b1b7af0015dd29e2bafff587664ff838
7
+ data.tar.gz: ec09e6586570025a8e02acb4d61f323f841201441341de7aa1cf3cfa621c74d331d1e48794e6e88e87990511e4a215233b990c86cfca11829aa52be0ea82b12a
data/exe/every CHANGED
@@ -2,5 +2,16 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require "everywhere/cli"
5
+ require "everywhere/ui"
5
6
 
6
- Dry::CLI.new(Everywhere::CLI).call
7
+ begin
8
+ Dry::CLI.new(Everywhere::CLI).call
9
+ rescue Interrupt
10
+ # ^C is an answer, not a crash: no backtrace, and the shell's own convention
11
+ # for "killed by SIGINT".
12
+ exit 130
13
+ rescue Everywhere::Error => e
14
+ # Everywhere::Fatal is a SystemExit, not an Error, so a die! that already
15
+ # printed its own line passes straight through here.
16
+ Everywhere::UI.die!(e.message)
17
+ end
data/exe/rbe CHANGED
@@ -2,5 +2,16 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require "everywhere/cli"
5
+ require "everywhere/ui"
5
6
 
6
- Dry::CLI.new(Everywhere::CLI).call
7
+ begin
8
+ Dry::CLI.new(Everywhere::CLI).call
9
+ rescue Interrupt
10
+ # ^C is an answer, not a crash: no backtrace, and the shell's own convention
11
+ # for "killed by SIGINT".
12
+ exit 130
13
+ rescue Everywhere::Error => e
14
+ # Everywhere::Fatal is a SystemExit, not an Error, so a die! that already
15
+ # printed its own line passes straight through here.
16
+ Everywhere::UI.die!(e.message)
17
+ end
@@ -51,7 +51,9 @@ module Everywhere
51
51
  path = File.join(template_dir, *parts)
52
52
  File.exist?(path) or UI.die!("couldn't find the bundled AGENTS.md template at #{path}; " \
53
53
  "reinstall ruby_everywhere")
54
- File.read(path)
54
+ # The templates are UTF-8 prose; under LANG=C the gsub below would raise
55
+ # on them rather than substitute.
56
+ File.read(path, encoding: "UTF-8").scrub
55
57
  end
56
58
  end
57
59
  end
@@ -68,7 +68,12 @@ module Everywhere
68
68
  rescue StandardError
69
69
  nil
70
70
  end
71
- return digest(File.binread(path), 64) unless md
71
+ # `digest` is one-shot (no incremental update), so the fallback has to hold
72
+ # the whole artifact in memory — worth warning about on a 300MB .zip.
73
+ unless md
74
+ warn_pure_ruby_fallback(path)
75
+ return digest(File.binread(path), 64)
76
+ end
72
77
 
73
78
  File.open(path, "rb") do |io|
74
79
  while (chunk = io.read(1 << 20))
@@ -78,6 +83,17 @@ module Everywhere
78
83
  md.digest
79
84
  end
80
85
 
86
+ # Once per process — signing prehashes the same artifact more than once.
87
+ def warn_pure_ruby_fallback(path)
88
+ return if @warned_fallback
89
+
90
+ @warned_fallback = true
91
+ require_relative "ui"
92
+ mb = (File.size(path) / 1024.0 / 1024.0).round(1)
93
+ Everywhere::UI.warn("this Ruby's OpenSSL has no BLAKE2b512 — hashing #{mb}MB in pure Ruby " \
94
+ "(slow, and the file is read into memory)")
95
+ end
96
+
81
97
  def compress(h, block, t, last)
82
98
  m = block.unpack("Q<16")
83
99
  v = h + IV
@@ -64,15 +64,32 @@ module Everywhere
64
64
  def default_app_data
65
65
  case RUBY_PLATFORM
66
66
  when /darwin/
67
- File.join(Dir.home, "Library", "Application Support", config.bundle_id)
67
+ File.join(home, "Library", "Application Support", config.bundle_id)
68
68
  when /mingw|mswin/
69
- File.join(ENV.fetch("APPDATA"), config.bundle_id)
69
+ File.join(app_data_env("APPDATA"), config.bundle_id)
70
70
  else
71
- File.join(ENV.fetch("XDG_DATA_HOME", File.join(Dir.home, ".local", "share")),
72
- config.bundle_id)
71
+ # `||`, not ENV.fetch's default: the fallback resolves a home that a
72
+ # machine setting XDG_DATA_HOME needn't have.
73
+ File.join(ENV["XDG_DATA_HOME"] || File.join(home, ".local", "share"), config.bundle_id)
73
74
  end
74
75
  end
75
76
 
77
+ # Only reached in a standalone run — the shell always passes NATIVE_APPDATA.
78
+ # Dir.home raises when HOME is unset and there's no passwd entry (a
79
+ # container run with --user), which is a bare ArgumentError otherwise.
80
+ def home
81
+ Dir.home
82
+ rescue ArgumentError
83
+ raise Error, app_data_hint("no home directory")
84
+ end
85
+
86
+ def app_data_env(key)
87
+ ENV[key] or raise Error, app_data_hint("#{key} is unset")
88
+ end
89
+
90
+ def app_data_hint(reason) = "can't work out where to keep app data (#{reason}) — " \
91
+ "set NATIVE_APPDATA (or HOME / APPDATA)"
92
+
76
93
  def prepare_app_data
77
94
  storage = File.join(app_data, "storage")
78
95
  FileUtils.mkdir_p(storage)
@@ -32,13 +32,34 @@ module Everywhere
32
32
  # app/src/stamped/res/drawable*/ native/android/assets/** as drawables
33
33
  # app/src/main/java/com/rubyeverywhere/shell/extensions/ native/android/*.kt + registry
34
34
  #
35
- # MVP scope is a debug/unsigned-release APK. Keystore signing, AAB and Play
36
- # upload hang off the same seam later — see docs/android-plan.md §9.
35
+ # Signing and the format Play accepts are driven from the environment, not
36
+ # from everywhere.yml — see SIGNING_ENV.
37
37
  class Android
38
38
  # Fixed by the template contract: one Gradle module, always :app, so
39
39
  # build paths and task names never depend on per-app values.
40
40
  GRADLE_MODULE = ":app"
41
41
 
42
+ # What each package format is called in the three places it has a name:
43
+ # the Gradle verb, the outputs/ subdirectory and the file extension.
44
+ FORMATS = {
45
+ apk: { task: "assemble", dir: "apk", extension: "apk" },
46
+ aab: { task: "bundle", dir: "bundle", extension: "aab" }
47
+ }.freeze
48
+
49
+ # Release signing is read from the environment and handed to Gradle as
50
+ # environment, never stamped into everywhere.properties: that file lives
51
+ # in the work dir for the life of the app, and a keystore password written
52
+ # there outlives the build that needed it. The keystore itself stays where
53
+ # the machine put it — nothing is copied into the project.
54
+ SIGNING_ENV = %w[EVERY_ANDROID_KEYSTORE EVERY_ANDROID_KEYSTORE_PASSWORD
55
+ EVERY_ANDROID_KEY_ALIAS EVERY_ANDROID_KEY_PASSWORD].freeze
56
+
57
+ # The two the keystore can't be used without. EVERY_ANDROID_KEY_PASSWORD
58
+ # is not among them: a key generated without -keypass shares the store's.
59
+ REQUIRED_SIGNING_ENV = %w[EVERY_ANDROID_KEYSTORE_PASSWORD EVERY_ANDROID_KEY_ALIAS].freeze
60
+
61
+ VERSION_CODE_ENV = "EVERY_ANDROID_VERSION_CODE"
62
+
42
63
  # The Kotlin package the shell's sources live in. NOT the applicationId:
43
64
  # the app id is stamped per app, the code's package is frozen, and mixing
44
65
  # them up is the classic Android manifest mistake — so anything we
@@ -103,24 +124,25 @@ module Everywhere
103
124
  @template_dir = template_dir
104
125
  end
105
126
 
106
- # Build the APK and copy it into dist_dir. `dev` builds the debug variant
127
+ # Build the app and copy it into dist_dir. `dev` builds the debug variant
107
128
  # and relaxes the remote-mode requirement (the dev URL is stamped into a
108
129
  # debug asset each run — Android has no SIMCTL_CHILD_* equivalent).
109
- # Returns the path of the collected APK.
110
- def build!(dist_dir:, dev: false, dev_url: nil)
130
+ # `format` is :apk (installable) or :aab (what Play accepts). Returns the
131
+ # path of the collected artifact.
132
+ def build!(dist_dir:, dev: false, dev_url: nil, format: :apk)
111
133
  # The template is resolved before preflight, not after it like iOS:
112
134
  # preflight has to settle the icon font, and the bundled font lives in
113
135
  # the template. Everything that can fail slowly (a font download) or
114
136
  # cheaply (a typo'd icon name) is therefore decided before Gradle — the
115
137
  # difference between a 5-second error and one that lands 90 seconds in.
116
138
  template = @template_dir ? File.expand_path(@template_dir) : Paths.android_dir!
117
- preflight!(dev: dev, template: template)
139
+ preflight!(dev: dev, format: format, template: template)
118
140
  work = stage(template)
119
141
  stamp!(work)
120
142
  stamp_dev_url!(work, dev_url)
121
143
  variant = dev ? "debug" : "release"
122
- gradle!(work, variant, dist_dir)
123
- collect(work, variant, dist_dir)
144
+ gradle!(work, variant, format, dist_dir)
145
+ collect(work, variant, format, dist_dir)
124
146
  end
125
147
 
126
148
  private
@@ -149,13 +171,17 @@ module Everywhere
149
171
  UI.step("pointing the shell at #{UI.cyan(dev_url)}")
150
172
  end
151
173
 
152
- def preflight!(dev:, template:)
174
+ def preflight!(dev:, format:, template:)
153
175
  # Config shape first, machine state second: a typo in everywhere.yml is
154
176
  # the app author's to fix wherever they are, while "no Android SDK"
155
177
  # depends on the machine they happen to be sitting at.
156
178
  errors = @config.native_android_errors
157
179
  UI.die!("native.android in config/everywhere.yml:\n#{errors.join("\n")}") unless errors.empty?
158
180
 
181
+ check_format!(dev: dev, format: format)
182
+ @signing = dev ? nil : signing_env!
183
+ @version_code = version_code
184
+
159
185
  unless dev || @config.remote?
160
186
  UI.die!("Android apps are remote mode only for now — set remote.url in config/everywhere.yml " \
161
187
  "(the shell wraps your deployed app; nothing is packaged locally)")
@@ -179,7 +205,10 @@ module Everywhere
179
205
  # fix — the analogue of the iOS xcode-select message.
180
206
  @sdk_root = AndroidSdk.sdk_root!
181
207
  @java_home = AndroidSdk.java_home!
182
- AndroidSdk.adb!
208
+ # adb only matters when something is going to be installed on a device,
209
+ # so it is a `dev` requirement and not a build one — a headless runner
210
+ # that builds a release AAB has no reason to carry platform-tools.
211
+ AndroidSdk.adb! if dev
183
212
  # A missing compile SDK surfaces from Gradle as "Failed to find target
184
213
  # with hash string android-35", minutes in and after the AGP download.
185
214
  AndroidSdk.platform!
@@ -187,6 +216,56 @@ module Everywhere
187
216
  resolve_icon_font!(template)
188
217
  end
189
218
 
219
+ def check_format!(dev:, format:)
220
+ unless FORMATS.key?(format)
221
+ UI.die!("#{format.inspect} isn't an Android package format — :apk (installable) or :aab " \
222
+ "(what Google Play accepts)")
223
+ end
224
+ return unless format == :aab && dev
225
+
226
+ UI.die!("an .aab can't be installed on a device or an emulator — it's an upload format Play " \
227
+ "re-signs into per-device APKs. `every dev` builds an APK; ask for the bundle when " \
228
+ "you're publishing")
229
+ end
230
+
231
+ # --- signing ---------------------------------------------------------------
232
+
233
+ # The signing environment Gradle is handed, or nil for the unsigned
234
+ # release the default build still produces. Resolved here rather than left
235
+ # to the build script so a half-configured keystore fails in seconds with
236
+ # the missing variable named, instead of as an AGP error two minutes in —
237
+ # or, worse, as an unsigned APK nobody notices until Play rejects it.
238
+ def signing_env!
239
+ keystore = env_value("EVERY_ANDROID_KEYSTORE") or return nil
240
+
241
+ missing = REQUIRED_SIGNING_ENV.reject { |name| env_value(name) }
242
+ unless missing.empty?
243
+ UI.die!("EVERY_ANDROID_KEYSTORE is set but #{missing.join(" and ")} " \
244
+ "#{missing.one? ? "is" : "are"} not — signing needs the keystore, its password " \
245
+ "and the alias of the key inside it (EVERY_ANDROID_KEY_PASSWORD falls back to " \
246
+ "the keystore password)")
247
+ end
248
+
249
+ path = File.expand_path(keystore)
250
+ UI.die!("EVERY_ANDROID_KEYSTORE points at #{path}, which isn't a file") unless File.file?(path)
251
+
252
+ UI.step("signing the release #{UI.dim("(#{env_value("EVERY_ANDROID_KEY_ALIAS")} in " \
253
+ "#{UI.short_path(path)})")}")
254
+ { "EVERY_ANDROID_KEYSTORE" => path,
255
+ "EVERY_ANDROID_KEYSTORE_PASSWORD" => env_value("EVERY_ANDROID_KEYSTORE_PASSWORD"),
256
+ "EVERY_ANDROID_KEY_ALIAS" => env_value("EVERY_ANDROID_KEY_ALIAS"),
257
+ "EVERY_ANDROID_KEY_PASSWORD" => env_value("EVERY_ANDROID_KEY_PASSWORD") ||
258
+ env_value("EVERY_ANDROID_KEYSTORE_PASSWORD") }
259
+ end
260
+
261
+ # An env var that was set to nothing is the same as unset here: CI writes
262
+ # `EVERY_ANDROID_KEYSTORE: ${{ secrets.KEYSTORE }}` whether or not the
263
+ # secret exists, and an empty keystore path must not look configured.
264
+ def env_value(name)
265
+ value = ENV[name].to_s.strip
266
+ value.empty? ? nil : value
267
+ end
268
+
190
269
  # Copy the template into the app's persistent work dir. Merged over
191
270
  # whatever is already there, NOT wiped like the iOS stage: Gradle's
192
271
  # incremental state (app/build/, .gradle/) lives *inside* this directory,
@@ -248,7 +327,7 @@ module Everywhere
248
327
  # belong in config/everywhere.yml.
249
328
  everywhere.applicationId=#{properties_value(application_id)}
250
329
  everywhere.versionName=#{properties_value(@config.version(target: @target))}
251
- everywhere.versionCode=#{version_code}
330
+ everywhere.versionCode=#{@version_code}
252
331
  everywhere.label=#{properties_value(name)}
253
332
  PROPERTIES
254
333
  end
@@ -257,7 +336,20 @@ module Everywhere
257
336
  # deriving it from the version the app already maintains beats asking for
258
337
  # a second number nobody remembers to bump. 1.2.3 → 10203, which stays
259
338
  # monotonic as long as minor/patch stay under 100.
339
+ #
340
+ # EVERY_ANDROID_VERSION_CODE overrides it outright, for the pipeline that
341
+ # numbers builds rather than versions them (a run number, a commit count).
260
342
  def version_code
343
+ override = env_value(VERSION_CODE_ENV) or return derived_version_code
344
+
345
+ unless override.match?(/\A[1-9]\d*\z/)
346
+ UI.die!("#{VERSION_CODE_ENV}=#{override.inspect} — a versionCode is a positive integer, " \
347
+ "and Play only accepts an upload whose code is higher than the last one's")
348
+ end
349
+ override.to_i
350
+ end
351
+
352
+ def derived_version_code
261
353
  major, minor, patch = @config.version(target: @target).to_s.split(".").map(&:to_i)
262
354
  code = ((major || 0) * 10_000) + ((minor || 0) * 100) + (patch || 0)
263
355
  [code, 1].max
@@ -796,45 +888,67 @@ module Everywhere
796
888
 
797
889
  # --- gradle ---------------------------------------------------------------
798
890
 
799
- def gradle!(work, variant, dist_dir)
800
- UI.step("building Android shell #{UI.dim("(Gradle, #{variant}, first run downloads " \
801
- "Gradle and the Android Gradle Plugin)")}")
891
+ def gradle!(work, variant, format, dist_dir)
892
+ UI.step("building Android shell #{UI.dim("(Gradle, #{variant} #{format.to_s.upcase}, first " \
893
+ "run downloads Gradle and the Android Gradle Plugin)")}")
802
894
  FileUtils.mkdir_p(dist_dir)
803
895
  log = File.join(dist_dir, "android-build.log")
804
- task = "#{GRADLE_MODULE}:assemble#{variant.capitalize}"
896
+ task = "#{GRADLE_MODULE}:#{FORMATS.fetch(format)[:task]}#{variant.capitalize}"
805
897
  # -p instead of a chdir: the wrapper finds its own jar from $0, so an
806
898
  # absolute gradlew with an explicit project dir works from anywhere
807
899
  # (Shellout.run_logged! has no chdir). --console=plain keeps the stream
808
900
  # line-oriented for LogFilter whether or not a TTY is attached.
809
901
  Shellout.run_logged!(gradle_env,
810
- [File.join(work, "gradlew"), "-p", work, "--console=plain", task],
902
+ [gradle_wrapper(work), "-p", work, "--console=plain", task],
811
903
  log: log, filter: LogFilter.for(:gradle))
812
904
  end
813
905
 
906
+ # The wrapper Windows can actually execute — the POSIX gradlew is a shell
907
+ # script there, and cmd.exe won't run it.
908
+ def gradle_wrapper(work)
909
+ File.join(work, AndroidSdk.windows? ? "gradlew.bat" : "gradlew")
910
+ end
911
+
814
912
  # The whole toolchain is handed to Gradle explicitly. ANDROID_HOME is what
815
913
  # AGP reads when there's no local.properties — and we never write one, so
816
914
  # the stamped project stays clean and Android Studio can resolve its own
817
915
  # SDK. GRADLE_USER_HOME redirects the 1.4 GB of Gradle distribution, AGP
818
916
  # and Hotwire artifacts out of the gem and into a cache shared by every app.
917
+ #
918
+ # The signing variables travel the same way rather than through a file the
919
+ # build script reads: env is the one channel that reaches the Gradle child
920
+ # without leaving a password on disk. The resolved values (absolute
921
+ # keystore path, key password defaulted to the store's) are what's passed,
922
+ # so the build script never has to repeat the fallback.
819
923
  def gradle_env
820
924
  { "JAVA_HOME" => @java_home,
821
925
  "ANDROID_HOME" => @sdk_root,
822
926
  "ANDROID_SDK_ROOT" => @sdk_root,
823
- "GRADLE_USER_HOME" => Paths.android_gradle_home }
824
- end
825
-
826
- # Copy the APK out of the module's build dir. Release is unsigned until
827
- # keystore signing lands (docs/android-plan.md §9), and AGP spells that
828
- # variant app-release-unsigned.apk accept either name so the collect
829
- # step doesn't have to be revisited when signing arrives.
830
- def collect(work, variant, dist_dir)
831
- dir = File.join(work, "app", "build", "outputs", "apk", variant)
832
- built = ["app-#{variant}.apk", "app-#{variant}-unsigned.apk"]
833
- .map { |file| File.join(dir, file) }
834
- .find { |path| File.file?(path) }
835
- UI.die!("Gradle reported success but no APK in #{UI.short_path(dir)}") unless built
927
+ "GRADLE_USER_HOME" => Paths.android_gradle_home,
928
+ VERSION_CODE_ENV => @version_code.to_s }.merge(@signing || {})
929
+ end
930
+
931
+ # Copy the built artifact out of the module's build dir. An unsigned
932
+ # release APK is spelled app-release-unsigned.apk by AGP, so both names
933
+ # are accepted but only when no keystore was configured: with one set,
934
+ # the unsigned name means the signingConfig didn't take, and shipping that
935
+ # silently is how an unsignable APK reaches Play. Bundles have no such
936
+ # spelling; bundleRelease writes app-release.aab either way.
937
+ def collect(work, variant, format, dist_dir)
938
+ spec = FORMATS.fetch(format)
939
+ kind = spec[:extension].upcase
940
+ dir = File.join(work, "app", "build", "outputs", spec[:dir], variant)
941
+ signed = File.join(dir, "app-#{variant}.#{spec[:extension]}")
942
+ unsigned = File.join(dir, "app-#{variant}-unsigned.#{spec[:extension]}")
943
+ built = [signed, unsigned].find { |path| File.file?(path) }
944
+ UI.die!("Gradle reported success but no #{kind} in #{UI.short_path(dir)}") unless built
945
+
946
+ if @signing && built == unsigned
947
+ UI.die!("Gradle wrote #{File.basename(built)} — EVERY_ANDROID_KEYSTORE is set, so the " \
948
+ "#{kind} should have been signed. Check the keystore's alias and passwords")
949
+ end
836
950
 
837
- dest = File.join(dist_dir, "#{name}.apk")
951
+ dest = File.join(dist_dir, "#{name}.#{spec[:extension]}")
838
952
  FileUtils.rm_f(dest)
839
953
  FileUtils.cp(built, dest)
840
954
  dest
@@ -10,19 +10,24 @@ module Everywhere
10
10
  # Resolves where cargo should run for the desktop shell, and stamps a
11
11
  # per-app copy of it when the app declares `native.desktop`.
12
12
  #
13
- # Two paths, deliberately:
13
+ # EVERY app gets its own staged copy of the shell under
14
+ # ~/.rubyeverywhere/desktop/<id>; cargo never runs in the template. Cargo
15
+ # writes Cargo.lock into the package dir and `every build` swaps the app's
16
+ # icon into icons/icon.png, so compiling the template in place means writing
17
+ # into the installed gem — Errno::EACCES on a system-wide install, and one
18
+ # app's branding in the next app's build when two builds share it.
14
19
  #
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
+ # What the two paths differ in is the stamp and the target dir:
20
21
  #
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.
22
+ # no native.desktop the template verbatim, compiled against the SHARED
23
+ # ~/.rubyeverywhere/shell-target. The source really is
24
+ # identical for every such app, so one warm target dir
25
+ # serves all of them.
26
+ #
27
+ # native.desktop the app's Rust stamped in, and its own target dir,
28
+ # exactly like the iOS and Android work dirs. Costs one
29
+ # cold Tauri compile (minutes) and a couple of GB,
30
+ # which is why it is not the default.
26
31
  #
27
32
  # The stamped list, and nothing else:
28
33
  #
@@ -59,14 +64,14 @@ module Everywhere
59
64
  # (`every dev`, `every build`) don't need to care which path they got.
60
65
  def prepare!
61
66
  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
67
+ stamped = @config.native_desktop?
68
+ preflight! if stamped
65
69
 
66
- preflight!
67
70
  src_tauri = stage(template)
68
- stamp!(src_tauri)
69
- Prepared.new(dir: src_tauri, target_dir: Paths.desktop_target_dir(bundle_id), stamped: true)
71
+ stamp!(src_tauri) if stamped
72
+ Prepared.new(dir: src_tauri,
73
+ target_dir: stamped ? Paths.desktop_target_dir(bundle_id) : Paths.cargo_target_dir,
74
+ stamped: stamped)
70
75
  end
71
76
 
72
77
  private
@@ -91,6 +96,9 @@ module Everywhere
91
96
  #
92
97
  # A gem upgrade wipes it: the template's own sources changed, and merging
93
98
  # new files over old ones would leave whatever the new template deleted.
99
+ # Re-copying the template every time is also what un-brands the shell: the
100
+ # build swaps the app's icon into the staged icons/icon.png, and the
101
+ # placeholder has to come back on its own when app.icon goes away.
94
102
  # `template` points at src-tauri (that's what --shell-dir means), but the
95
103
  # whole shell root has to come along: tauri.conf.json's frontendDist is
96
104
  # "../splash", a sibling of src-tauri, and generate_context! panics at