ruby_everywhere 0.6.0 → 0.8.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 (81) hide show
  1. checksums.yaml +4 -4
  2. data/exe/every +2 -2
  3. data/exe/rbe +2 -2
  4. data/lib/everywhere/agents_guide.rb +3 -1
  5. data/lib/everywhere/blake2b.rb +17 -1
  6. data/lib/everywhere/boot.rb +30 -6
  7. data/lib/everywhere/builders/android.rb +152 -64
  8. data/lib/everywhere/builders/base.rb +53 -0
  9. data/lib/everywhere/builders/desktop.rb +32 -40
  10. data/lib/everywhere/builders/ios.rb +268 -36
  11. data/lib/everywhere/builders/native_sources.rb +38 -0
  12. data/lib/everywhere/child_processes.rb +4 -4
  13. data/lib/everywhere/child_supervision.rb +172 -0
  14. data/lib/everywhere/cli.rb +2 -0
  15. data/lib/everywhere/clock.rb +12 -0
  16. data/lib/everywhere/commands/build.rb +132 -66
  17. data/lib/everywhere/commands/clean.rb +8 -3
  18. data/lib/everywhere/commands/dev.rb +91 -242
  19. data/lib/everywhere/commands/doctor.rb +138 -21
  20. data/lib/everywhere/commands/install.rb +49 -8
  21. data/lib/everywhere/commands/platform/auth_status.rb +1 -0
  22. data/lib/everywhere/commands/platform/build.rb +141 -33
  23. data/lib/everywhere/commands/platform/login.rb +20 -6
  24. data/lib/everywhere/commands/platform/logout.rb +1 -0
  25. data/lib/everywhere/commands/platform/runner.rb +170 -25
  26. data/lib/everywhere/commands/preview.rb +286 -0
  27. data/lib/everywhere/commands/publish.rb +22 -2
  28. data/lib/everywhere/commands/release.rb +155 -35
  29. data/lib/everywhere/commands/shell_dir.rb +4 -2
  30. data/lib/everywhere/commands/updates_keygen.rb +25 -1
  31. data/lib/everywhere/config/app.rb +126 -0
  32. data/lib/everywhere/config/auth.rb +108 -0
  33. data/lib/everywhere/config/data.rb +50 -0
  34. data/lib/everywhere/config/deep_linking.rb +107 -0
  35. data/lib/everywhere/config/desktop_ui.rb +153 -0
  36. data/lib/everywhere/config/mobile.rb +211 -0
  37. data/lib/everywhere/config/native_desktop.rb +168 -0
  38. data/lib/everywhere/config/native_mobile.rb +337 -0
  39. data/lib/everywhere/config/shell.rb +57 -0
  40. data/lib/everywhere/config/updates.rb +63 -0
  41. data/lib/everywhere/config.rb +59 -1367
  42. data/lib/everywhere/console.rb +2 -1
  43. data/lib/everywhere/desktop_dev_app.rb +138 -0
  44. data/lib/everywhere/dock/state.rb +3 -1
  45. data/lib/everywhere/engine.rb +24 -0
  46. data/lib/everywhere/entrypoint.rb +24 -0
  47. data/lib/everywhere/error.rb +8 -0
  48. data/lib/everywhere/framework.rb +23 -6
  49. data/lib/everywhere/host.rb +11 -0
  50. data/lib/everywhere/ignore.rb +13 -5
  51. data/lib/everywhere/jump.rb +121 -0
  52. data/lib/everywhere/line_pump.rb +3 -1
  53. data/lib/everywhere/minisign.rb +1 -0
  54. data/lib/everywhere/mobile_config_endpoint.rb +47 -0
  55. data/lib/everywhere/mobile_configs_controller.rb +46 -0
  56. data/lib/everywhere/native_platform.rb +44 -0
  57. data/lib/everywhere/paths.rb +36 -13
  58. data/lib/everywhere/platform/client.rb +46 -7
  59. data/lib/everywhere/platform/credentials.rb +18 -8
  60. data/lib/everywhere/platform/snapshot.rb +12 -0
  61. data/lib/everywhere/plist.rb +17 -0
  62. data/lib/everywhere/png.rb +1 -0
  63. data/lib/everywhere/raw_tty.rb +51 -0
  64. data/lib/everywhere/receipt.rb +55 -10
  65. data/lib/everywhere/s3.rb +1 -0
  66. data/lib/everywhere/shell_pages.rb +109 -0
  67. data/lib/everywhere/shellout.rb +19 -0
  68. data/lib/everywhere/simulator.rb +12 -1
  69. data/lib/everywhere/tab_filter.rb +36 -0
  70. data/lib/everywhere/task_pool.rb +3 -4
  71. data/lib/everywhere/ui.rb +6 -1
  72. data/lib/everywhere/version.rb +6 -4
  73. data/lib/everywhere.rb +1 -2
  74. data/support/mobile/android/app/build.gradle.kts +29 -1
  75. data/support/mobile/ios/App/EverywhereConfig.swift +17 -5
  76. data/support/mobile/ios/App/SceneDelegate.swift +75 -8
  77. data/support/mobile/ios/App.xcodeproj/project.pbxproj +2 -4
  78. data/support/mobile/ios/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +0 -1
  79. data/support/mobile/ios/README.md +13 -6
  80. data/support/release/macos/notarize.sh +3 -0
  81. metadata +40 -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: a1644d6c9be14fdb8ef738eaee6f33225e5916ea401945f7c6c494e8963a2c16
4
+ data.tar.gz: 03621b7846f16b5621a352e7d4fc42ac38c8859095fc1be1098eef48b081fb0f
5
5
  SHA512:
6
- metadata.gz: 69fb1bdb659b8b886d9b602e09e21d8016caf140ac401ee371d4b0eaab2e06f3b342fb7742e2684570b80e71768fb2c5ede34dca3f1d5b9015185b93b176ba59
7
- data.tar.gz: 450a804d3bf9765ea664e0728c66df31019e5633393680120ef9ec7ba40def7e69f163b9aaaf6b2d2fcec948e62c5d3821b26bb553924666dfb57b7d1c6b29cd
6
+ metadata.gz: 4de42927fa8a53c136e0f9a3015ee1f930199642a26504d198673d0fe3e8cc24e32e9db219d34a170023809db8a9fb20715a6577e13cc6bb030fc9e3a8600b45
7
+ data.tar.gz: 58da2af357a422bd9dd87badf31175ba71767a5c3364d52f875690fd217a0be15cf1b12296bb195332ebec015ede65b7f486e3c1bf65ff66d5d0cb6831a20e3f
data/exe/every CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env ruby
2
2
  # frozen_string_literal: true
3
3
 
4
- require "everywhere/cli"
4
+ require "everywhere/entrypoint"
5
5
 
6
- Dry::CLI.new(Everywhere::CLI).call
6
+ Everywhere::Entrypoint.run
data/exe/rbe CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env ruby
2
2
  # frozen_string_literal: true
3
3
 
4
- require "everywhere/cli"
4
+ require "everywhere/entrypoint"
5
5
 
6
- Dry::CLI.new(Everywhere::CLI).call
6
+ Everywhere::Entrypoint.run
@@ -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
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "fileutils"
4
4
  require "securerandom"
5
+ require_relative "error"
5
6
 
6
7
  module Everywhere
7
8
  # Runtime boot for the packaged desktop build. Called from the app's
@@ -64,15 +65,32 @@ module Everywhere
64
65
  def default_app_data
65
66
  case RUBY_PLATFORM
66
67
  when /darwin/
67
- File.join(Dir.home, "Library", "Application Support", config.bundle_id)
68
+ File.join(home, "Library", "Application Support", config.bundle_id)
68
69
  when /mingw|mswin/
69
- File.join(ENV.fetch("APPDATA"), config.bundle_id)
70
+ File.join(app_data_env("APPDATA"), config.bundle_id)
70
71
  else
71
- File.join(ENV.fetch("XDG_DATA_HOME", File.join(Dir.home, ".local", "share")),
72
- config.bundle_id)
72
+ # `||`, not ENV.fetch's default: the fallback resolves a home that a
73
+ # machine setting XDG_DATA_HOME needn't have.
74
+ File.join(ENV["XDG_DATA_HOME"] || File.join(home, ".local", "share"), config.bundle_id)
73
75
  end
74
76
  end
75
77
 
78
+ # Only reached in a standalone run — the shell always passes NATIVE_APPDATA.
79
+ # Dir.home raises when HOME is unset and there's no passwd entry (a
80
+ # container run with --user), which is a bare ArgumentError otherwise.
81
+ def home
82
+ Dir.home
83
+ rescue ArgumentError
84
+ raise Error, app_data_hint("no home directory")
85
+ end
86
+
87
+ def app_data_env(key)
88
+ ENV[key] or raise Error, app_data_hint("#{key} is unset")
89
+ end
90
+
91
+ def app_data_hint(reason) = "can't work out where to keep app data (#{reason}) — " \
92
+ "set NATIVE_APPDATA (or HOME / APPDATA)"
93
+
76
94
  def prepare_app_data
77
95
  storage = File.join(app_data, "storage")
78
96
  FileUtils.mkdir_p(storage)
@@ -124,8 +142,14 @@ module Everywhere
124
142
  def ensure_secret_key_base
125
143
  secret_file = File.join(app_data, "secret_key_base")
126
144
  unless File.exist?(secret_file)
127
- File.write(secret_file, SecureRandom.hex(64))
128
- File.chmod(0o600, secret_file)
145
+ # Created 0600 rather than written then chmod'ed: this signs the app's
146
+ # cookies, and write-then-tighten leaves it world-readable in between.
147
+ # EXCL so a racing boot doesn't clobber the one already in use.
148
+ begin
149
+ File.open(secret_file, File::WRONLY | File::CREAT | File::EXCL, 0o600) { |f| f.write(SecureRandom.hex(64)) }
150
+ rescue Errno::EEXIST
151
+ nil
152
+ end
129
153
  end
130
154
  ENV["SECRET_KEY_BASE"] ||= File.read(secret_file).strip
131
155
  end
@@ -10,6 +10,8 @@ require_relative "../android_resources"
10
10
  require_relative "../android_sdk"
11
11
  require_relative "../version"
12
12
  require_relative "../ui"
13
+ require_relative "base"
14
+ require_relative "native_sources"
13
15
 
14
16
  module Everywhere
15
17
  module Builders
@@ -32,13 +34,34 @@ module Everywhere
32
34
  # app/src/stamped/res/drawable*/ native/android/assets/** as drawables
33
35
  # app/src/main/java/com/rubyeverywhere/shell/extensions/ native/android/*.kt + registry
34
36
  #
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.
37
- class Android
37
+ # Signing and the format Play accepts are driven from the environment, not
38
+ # from everywhere.yml — see SIGNING_ENV.
39
+ class Android < Base
38
40
  # Fixed by the template contract: one Gradle module, always :app, so
39
41
  # build paths and task names never depend on per-app values.
40
42
  GRADLE_MODULE = ":app"
41
43
 
44
+ # What each package format is called in the three places it has a name:
45
+ # the Gradle verb, the outputs/ subdirectory and the file extension.
46
+ FORMATS = {
47
+ apk: { task: "assemble", dir: "apk", extension: "apk" },
48
+ aab: { task: "bundle", dir: "bundle", extension: "aab" }
49
+ }.freeze
50
+
51
+ # Release signing is read from the environment and handed to Gradle as
52
+ # environment, never stamped into everywhere.properties: that file lives
53
+ # in the work dir for the life of the app, and a keystore password written
54
+ # there outlives the build that needed it. The keystore itself stays where
55
+ # the machine put it — nothing is copied into the project.
56
+ SIGNING_ENV = %w[EVERY_ANDROID_KEYSTORE EVERY_ANDROID_KEYSTORE_PASSWORD
57
+ EVERY_ANDROID_KEY_ALIAS EVERY_ANDROID_KEY_PASSWORD].freeze
58
+
59
+ # The two the keystore can't be used without. EVERY_ANDROID_KEY_PASSWORD
60
+ # is not among them: a key generated without -keypass shares the store's.
61
+ REQUIRED_SIGNING_ENV = %w[EVERY_ANDROID_KEYSTORE_PASSWORD EVERY_ANDROID_KEY_ALIAS].freeze
62
+
63
+ VERSION_CODE_ENV = "EVERY_ANDROID_VERSION_CODE"
64
+
42
65
  # The Kotlin package the shell's sources live in. NOT the applicationId:
43
66
  # the app id is stamped per app, the code's package is frozen, and mixing
44
67
  # them up is the classic Android manifest mistake — so anything we
@@ -86,47 +109,36 @@ module Everywhere
86
109
  # keeps its fonts out of assets/ still resolves.
87
110
  BUNDLED_FONT_DIRS = [%w[app src main assets fonts], %w[fonts]].freeze
88
111
 
89
- # Marks which template a work dir was staged from. A gem upgrade can add,
90
- # rename or delete template files, and staging merges rather than wipes
91
- # (see #stage), so a stale Kotlin file from an older template would keep
92
- # compiling in. Version mismatch = wipe once, then merge as usual.
93
- STAGE_MARKER = ".everywhere-template"
94
-
95
112
  # What AGP accepts as an applicationId: two or more segments, each a Java
96
113
  # identifier starting with a letter. No hyphens, unlike a bundle id.
97
114
  APPLICATION_ID = /\A[a-zA-Z]\w*(\.[a-zA-Z]\w*)+\z/
98
115
 
99
- def initialize(config:, root:, target:, template_dir: nil)
100
- @config = config
101
- @root = root
102
- @target = target
103
- @template_dir = template_dir
104
- end
105
-
106
- # Build the APK and copy it into dist_dir. `dev` builds the debug variant
116
+ # Build the app and copy it into dist_dir. `dev` builds the debug variant
107
117
  # and relaxes the remote-mode requirement (the dev URL is stamped into a
108
118
  # 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)
119
+ # `format` is :apk (installable) or :aab (what Play accepts). Returns the
120
+ # path of the collected artifact.
121
+ def build!(dist_dir:, dev: false, dev_url: nil, format: :apk)
111
122
  # The template is resolved before preflight, not after it like iOS:
112
123
  # preflight has to settle the icon font, and the bundled font lives in
113
124
  # the template. Everything that can fail slowly (a font download) or
114
125
  # cheaply (a typo'd icon name) is therefore decided before Gradle — the
115
126
  # difference between a 5-second error and one that lands 90 seconds in.
116
127
  template = @template_dir ? File.expand_path(@template_dir) : Paths.android_dir!
117
- preflight!(dev: dev, template: template)
128
+ preflight!(dev: dev, format: format, template: template)
118
129
  work = stage(template)
119
130
  stamp!(work)
120
131
  stamp_dev_url!(work, dev_url)
121
132
  variant = dev ? "debug" : "release"
122
- gradle!(work, variant, dist_dir)
123
- collect(work, variant, dist_dir)
133
+ gradle!(work, variant, format, dist_dir)
134
+ collect(work, variant, format, dist_dir)
124
135
  end
125
136
 
126
137
  private
127
138
 
128
- def name = @config.name(target: @target)
129
- def application_id = @config.bundle_id(target: @target)
139
+ # AGP name for the bundle id, kept because everything Android-side (the
140
+ # properties file, the work dir, the validation) says applicationId.
141
+ def application_id = bundle_id
130
142
 
131
143
  # The dev-server URL, for the debug source set only (EverywhereConfig's
132
144
  # devUrl reads it, and release builds never look). This is the whole of
@@ -149,13 +161,17 @@ module Everywhere
149
161
  UI.step("pointing the shell at #{UI.cyan(dev_url)}")
150
162
  end
151
163
 
152
- def preflight!(dev:, template:)
164
+ def preflight!(dev:, format:, template:)
153
165
  # Config shape first, machine state second: a typo in everywhere.yml is
154
166
  # the app author's to fix wherever they are, while "no Android SDK"
155
167
  # depends on the machine they happen to be sitting at.
156
168
  errors = @config.native_android_errors
157
169
  UI.die!("native.android in config/everywhere.yml:\n#{errors.join("\n")}") unless errors.empty?
158
170
 
171
+ check_format!(dev: dev, format: format)
172
+ @signing = dev ? nil : signing_env!
173
+ @version_code = version_code
174
+
159
175
  unless dev || @config.remote?
160
176
  UI.die!("Android apps are remote mode only for now — set remote.url in config/everywhere.yml " \
161
177
  "(the shell wraps your deployed app; nothing is packaged locally)")
@@ -179,7 +195,10 @@ module Everywhere
179
195
  # fix — the analogue of the iOS xcode-select message.
180
196
  @sdk_root = AndroidSdk.sdk_root!
181
197
  @java_home = AndroidSdk.java_home!
182
- AndroidSdk.adb!
198
+ # adb only matters when something is going to be installed on a device,
199
+ # so it is a `dev` requirement and not a build one — a headless runner
200
+ # that builds a release AAB has no reason to carry platform-tools.
201
+ AndroidSdk.adb! if dev
183
202
  # A missing compile SDK surfaces from Gradle as "Failed to find target
184
203
  # with hash string android-35", minutes in and after the AGP download.
185
204
  AndroidSdk.platform!
@@ -187,6 +206,48 @@ module Everywhere
187
206
  resolve_icon_font!(template)
188
207
  end
189
208
 
209
+ def check_format!(dev:, format:)
210
+ unless FORMATS.key?(format)
211
+ UI.die!("#{format.inspect} isn't an Android package format — :apk (installable) or :aab " \
212
+ "(what Google Play accepts)")
213
+ end
214
+ return unless format == :aab && dev
215
+
216
+ UI.die!("an .aab can't be installed on a device or an emulator — it's an upload format Play " \
217
+ "re-signs into per-device APKs. `every dev` builds an APK; ask for the bundle when " \
218
+ "you're publishing")
219
+ end
220
+
221
+ # --- signing ---------------------------------------------------------------
222
+
223
+ # The signing environment Gradle is handed, or nil for the unsigned
224
+ # release the default build still produces. Resolved here rather than left
225
+ # to the build script so a half-configured keystore fails in seconds with
226
+ # the missing variable named, instead of as an AGP error two minutes in —
227
+ # or, worse, as an unsigned APK nobody notices until Play rejects it.
228
+ def signing_env!
229
+ keystore = env_value("EVERY_ANDROID_KEYSTORE") or return nil
230
+
231
+ missing = REQUIRED_SIGNING_ENV.reject { |name| env_value(name) }
232
+ unless missing.empty?
233
+ UI.die!("EVERY_ANDROID_KEYSTORE is set but #{missing.join(" and ")} " \
234
+ "#{missing.one? ? "is" : "are"} not — signing needs the keystore, its password " \
235
+ "and the alias of the key inside it (EVERY_ANDROID_KEY_PASSWORD falls back to " \
236
+ "the keystore password)")
237
+ end
238
+
239
+ path = File.expand_path(keystore)
240
+ UI.die!("EVERY_ANDROID_KEYSTORE points at #{path}, which isn't a file") unless File.file?(path)
241
+
242
+ UI.step("signing the release #{UI.dim("(#{env_value("EVERY_ANDROID_KEY_ALIAS")} in " \
243
+ "#{UI.short_path(path)})")}")
244
+ { "EVERY_ANDROID_KEYSTORE" => path,
245
+ "EVERY_ANDROID_KEYSTORE_PASSWORD" => env_value("EVERY_ANDROID_KEYSTORE_PASSWORD"),
246
+ "EVERY_ANDROID_KEY_ALIAS" => env_value("EVERY_ANDROID_KEY_ALIAS"),
247
+ "EVERY_ANDROID_KEY_PASSWORD" => env_value("EVERY_ANDROID_KEY_PASSWORD") ||
248
+ env_value("EVERY_ANDROID_KEYSTORE_PASSWORD") }
249
+ end
250
+
190
251
  # Copy the template into the app's persistent work dir. Merged over
191
252
  # whatever is already there, NOT wiped like the iOS stage: Gradle's
192
253
  # incremental state (app/build/, .gradle/) lives *inside* this directory,
@@ -202,15 +263,10 @@ module Everywhere
202
263
  # non-executable gradlew fails with a bare "Permission denied".
203
264
  wrapper = File.join(work, "gradlew")
204
265
  FileUtils.chmod("u+x", wrapper) if File.file?(wrapper)
205
- File.write(File.join(work, STAGE_MARKER), "#{Everywhere::VERSION}\n")
266
+ mark_staged!(work)
206
267
  work
207
268
  end
208
269
 
209
- def staged_version(work)
210
- marker = File.join(work, STAGE_MARKER)
211
- File.read(marker).strip if File.file?(marker)
212
- end
213
-
214
270
  # The whole per-app surface: identity properties, two JSON assets, colors,
215
271
  # launcher icons, the icon font, the merged-in manifest, the Extensions
216
272
  # sources + generated registry, and the Maven coordinate script. No build
@@ -248,7 +304,7 @@ module Everywhere
248
304
  # belong in config/everywhere.yml.
249
305
  everywhere.applicationId=#{properties_value(application_id)}
250
306
  everywhere.versionName=#{properties_value(@config.version(target: @target))}
251
- everywhere.versionCode=#{version_code}
307
+ everywhere.versionCode=#{@version_code}
252
308
  everywhere.label=#{properties_value(name)}
253
309
  PROPERTIES
254
310
  end
@@ -257,7 +313,20 @@ module Everywhere
257
313
  # deriving it from the version the app already maintains beats asking for
258
314
  # a second number nobody remembers to bump. 1.2.3 → 10203, which stays
259
315
  # monotonic as long as minor/patch stay under 100.
316
+ #
317
+ # EVERY_ANDROID_VERSION_CODE overrides it outright, for the pipeline that
318
+ # numbers builds rather than versions them (a run number, a commit count).
260
319
  def version_code
320
+ override = env_value(VERSION_CODE_ENV) or return derived_version_code
321
+
322
+ unless override.match?(/\A[1-9]\d*\z/)
323
+ UI.die!("#{VERSION_CODE_ENV}=#{override.inspect} — a versionCode is a positive integer, " \
324
+ "and Play only accepts an upload whose code is higher than the last one's")
325
+ end
326
+ override.to_i
327
+ end
328
+
329
+ def derived_version_code
261
330
  major, minor, patch = @config.version(target: @target).to_s.split(".").map(&:to_i)
262
331
  code = ((major || 0) * 10_000) + ((minor || 0) * 100) + (patch || 0)
263
332
  [code, 1].max
@@ -473,7 +542,13 @@ module Everywhere
473
542
  when Net::HTTPRedirection
474
543
  raise "too many redirects for #{url}" if redirects <= 0
475
544
 
476
- return fetch!(response["location"], dest, sha256: sha256, redirects: redirects - 1)
545
+ # The font is checksummed, so a swapped body is caught anyway —
546
+ # but the base is https and a redirect has no business dropping
547
+ # to http on the way to a cached artifact.
548
+ target = URI.join(uri, response["location"].to_s)
549
+ raise "refusing a non-https redirect to #{target.host}" unless target.scheme == "https"
550
+
551
+ return fetch!(target.to_s, dest, sha256: sha256, redirects: redirects - 1)
477
552
  when Net::HTTPSuccess
478
553
  File.open(part, "wb") { |f| response.read_body { |chunk| f.write(chunk) } }
479
554
  else
@@ -672,18 +747,9 @@ module Everywhere
672
747
  FileUtils.rm_rf(dest)
673
748
  FileUtils.mkdir_p(dest)
674
749
 
675
- source = File.join(@root, "native", "android")
676
- sources = File.directory?(source) ? Dir.glob(File.join(source, "**", "*.kt")).sort : []
677
- sources.each do |file|
678
- relative = file.delete_prefix("#{source}/")
679
- if File.basename(file) == "EverywhereExtensions.kt"
680
- UI.die!("native/android/#{relative}: EverywhereExtensions.kt is generated by " \
681
- "`every build` — declare your types under native.android in config/everywhere.yml instead")
682
- end
750
+ sources = NativeSources.copy!(from: File.join(@root, "native", "android"), into: dest, ext: "kt",
751
+ reserved: "EverywhereExtensions.kt", label: "native/android") do |file, relative|
683
752
  check_package!(file, relative)
684
- target_file = File.join(dest, relative)
685
- FileUtils.mkdir_p(File.dirname(target_file))
686
- FileUtils.cp(file, target_file)
687
753
  end
688
754
 
689
755
  if @config.native_android?
@@ -796,45 +862,67 @@ module Everywhere
796
862
 
797
863
  # --- gradle ---------------------------------------------------------------
798
864
 
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)")}")
865
+ def gradle!(work, variant, format, dist_dir)
866
+ UI.step("building Android shell #{UI.dim("(Gradle, #{variant} #{format.to_s.upcase}, first " \
867
+ "run downloads Gradle and the Android Gradle Plugin)")}")
802
868
  FileUtils.mkdir_p(dist_dir)
803
869
  log = File.join(dist_dir, "android-build.log")
804
- task = "#{GRADLE_MODULE}:assemble#{variant.capitalize}"
870
+ task = "#{GRADLE_MODULE}:#{FORMATS.fetch(format)[:task]}#{variant.capitalize}"
805
871
  # -p instead of a chdir: the wrapper finds its own jar from $0, so an
806
872
  # absolute gradlew with an explicit project dir works from anywhere
807
873
  # (Shellout.run_logged! has no chdir). --console=plain keeps the stream
808
874
  # line-oriented for LogFilter whether or not a TTY is attached.
809
875
  Shellout.run_logged!(gradle_env,
810
- [File.join(work, "gradlew"), "-p", work, "--console=plain", task],
876
+ [gradle_wrapper(work), "-p", work, "--console=plain", task],
811
877
  log: log, filter: LogFilter.for(:gradle))
812
878
  end
813
879
 
880
+ # The wrapper Windows can actually execute — the POSIX gradlew is a shell
881
+ # script there, and cmd.exe won't run it.
882
+ def gradle_wrapper(work)
883
+ File.join(work, AndroidSdk.windows? ? "gradlew.bat" : "gradlew")
884
+ end
885
+
814
886
  # The whole toolchain is handed to Gradle explicitly. ANDROID_HOME is what
815
887
  # AGP reads when there's no local.properties — and we never write one, so
816
888
  # the stamped project stays clean and Android Studio can resolve its own
817
889
  # SDK. GRADLE_USER_HOME redirects the 1.4 GB of Gradle distribution, AGP
818
890
  # and Hotwire artifacts out of the gem and into a cache shared by every app.
891
+ #
892
+ # The signing variables travel the same way rather than through a file the
893
+ # build script reads: env is the one channel that reaches the Gradle child
894
+ # without leaving a password on disk. The resolved values (absolute
895
+ # keystore path, key password defaulted to the store's) are what's passed,
896
+ # so the build script never has to repeat the fallback.
819
897
  def gradle_env
820
898
  { "JAVA_HOME" => @java_home,
821
899
  "ANDROID_HOME" => @sdk_root,
822
900
  "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
901
+ "GRADLE_USER_HOME" => Paths.android_gradle_home,
902
+ VERSION_CODE_ENV => @version_code.to_s }.merge(@signing || {})
903
+ end
904
+
905
+ # Copy the built artifact out of the module's build dir. An unsigned
906
+ # release APK is spelled app-release-unsigned.apk by AGP, so both names
907
+ # are accepted but only when no keystore was configured: with one set,
908
+ # the unsigned name means the signingConfig didn't take, and shipping that
909
+ # silently is how an unsignable APK reaches Play. Bundles have no such
910
+ # spelling; bundleRelease writes app-release.aab either way.
911
+ def collect(work, variant, format, dist_dir)
912
+ spec = FORMATS.fetch(format)
913
+ kind = spec[:extension].upcase
914
+ dir = File.join(work, "app", "build", "outputs", spec[:dir], variant)
915
+ signed = File.join(dir, "app-#{variant}.#{spec[:extension]}")
916
+ unsigned = File.join(dir, "app-#{variant}-unsigned.#{spec[:extension]}")
917
+ built = [signed, unsigned].find { |path| File.file?(path) }
918
+ UI.die!("Gradle reported success but no #{kind} in #{UI.short_path(dir)}") unless built
919
+
920
+ if @signing && built == unsigned
921
+ UI.die!("Gradle wrote #{File.basename(built)} — EVERY_ANDROID_KEYSTORE is set, so the " \
922
+ "#{kind} should have been signed. Check the keystore's alias and passwords")
923
+ end
836
924
 
837
- dest = File.join(dist_dir, "#{name}.apk")
925
+ dest = File.join(dist_dir, "#{name}.#{spec[:extension]}")
838
926
  FileUtils.rm_f(dest)
839
927
  FileUtils.cp(built, dest)
840
928
  dest
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../version"
4
+
5
+ module Everywhere
6
+ module Builders
7
+ # What the three platform builders (Android, Ios, Desktop) genuinely share.
8
+ # Deliberately thin: `stage`, `preflight!`, `stamp!` and `collect` are
9
+ # structurally parallel across the three but mean different things on each
10
+ # platform, so they stay where they are. Only identity (name, bundle id),
11
+ # the environment reader and the stage marker live here.
12
+ class Base
13
+ # Marks which template a work dir was staged from. A gem upgrade can add,
14
+ # rename or delete template files, and staging merges rather than wipes
15
+ # (see #stage), so a stale Kotlin file from an older template would keep
16
+ # compiling in. Version mismatch = wipe once, then merge as usual.
17
+ STAGE_MARKER = ".everywhere-template"
18
+
19
+ # `target` is a build target like "ios-arm64" or "android-arm64"; the
20
+ # desktop builder has none, and everything below reads it as nil.
21
+ def initialize(config:, root:, target: nil, template_dir: nil)
22
+ @config = config
23
+ @root = root
24
+ @target = target
25
+ @template_dir = template_dir
26
+ end
27
+
28
+ private
29
+
30
+ def name = @config.name(target: @target)
31
+ def bundle_id = @config.bundle_id(target: @target)
32
+
33
+ # An env var that was set to nothing is the same as unset here: CI writes
34
+ # `EVERY_ANDROID_KEYSTORE: ${{ secrets.KEYSTORE }}` whether or not the
35
+ # secret exists, and an empty keystore path must not look configured.
36
+ def env_value(name)
37
+ value = ENV[name].to_s.strip
38
+ value.empty? ? nil : value
39
+ end
40
+
41
+ def staged_version(work)
42
+ marker = File.join(work, STAGE_MARKER)
43
+ File.read(marker).strip if File.file?(marker)
44
+ end
45
+
46
+ # Records which gem version staged this work dir, for the next run to
47
+ # read back.
48
+ def mark_staged!(work)
49
+ File.write(File.join(work, STAGE_MARKER), "#{Everywhere::VERSION}\n")
50
+ end
51
+ end
52
+ end
53
+ end