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
@@ -1,7 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "dry/cli"
4
+ require "digest"
5
+ require "json"
4
6
  require_relative "../shellout"
7
+ require_relative "../host"
5
8
  require_relative "../log_filter"
6
9
  require_relative "../receipt"
7
10
  require_relative "../paths"
@@ -17,75 +20,181 @@ module Everywhere
17
20
  # surface (local, CI, Platform) must produce. See
18
21
  # platform/docs/build-engine.md §4, §7, §9.
19
22
  #
20
- # Today only the macos builder is live; the target is parameterized so
21
- # ios/android/windows/linux slot in as builders later.
23
+ # macos/ios/android are live; windows/linux slot in as builders later.
22
24
  class Release < Dry::CLI::Command
23
25
  NOTARIZE = File.expand_path("../../../support/release/macos/notarize.sh", __dir__)
24
26
 
27
+ # What counts as "the distributable" per os, newest first when several
28
+ # match. Android is listed aab-then-apk so a `--format aab` run picks the
29
+ # bundle even when an older apk is still sitting in dist/.
30
+ ARTIFACTS = { "macos" => %w[*.zip], "ios" => %w[*.ipa], "android" => %w[*.aab *.apk] }.freeze
31
+
25
32
  desc "Sign, notarize, staple, and emit release.json for a distributable build"
26
33
 
27
34
  option :root, default: ".", desc: "App root directory"
28
- option :target, default: "macos-arm64", desc: "Build target (os-arch)"
35
+ option :target, default: Build::DEFAULT_TARGET, desc: "Build target (os-arch)"
29
36
  option :channel, default: "direct", desc: "Distribution channel (direct | mac_app_store | ...)"
30
37
  option :ruby, default: nil, desc: "Ruby version to package (default: build.ruby or 4.0.6)"
31
38
  option :entry, default: "native_boot.rb", desc: "Entry point script relative to root"
32
39
  option :app_name, default: nil, desc: "Bundle name (default from config)"
33
40
  option :shell_dir, default: nil, desc: "Path to the shell's src-tauri directory"
34
41
  option :env_file, default: nil, desc: "dotenv file with APPLE_* signing secrets (default: .env.release)"
35
- option :skip_build, type: :boolean, default: false, desc: "Notarize the existing .app instead of rebuilding"
42
+ option :format, default: "apk", desc: "Android artifact format (apk | aab)"
43
+ option :skip_build, type: :boolean, default: false, desc: "Sign the existing build instead of rebuilding"
36
44
 
37
- def call(root: ".", target: "macos-arm64", channel: "direct", ruby: nil, entry: "native_boot.rb",
38
- app_name: nil, shell_dir: nil, env_file: nil, skip_build: false, **)
45
+ def call(root: ".", target: Build::DEFAULT_TARGET, channel: "direct", ruby: nil, entry: "native_boot.rb",
46
+ app_name: nil, shell_dir: nil, env_file: nil, format: "apk", skip_build: false, **)
39
47
  root_path = File.expand_path(root)
40
48
  config = Everywhere::Config.load(root_path)
41
49
  app_name ||= config.name(target: target)
42
50
  ruby ||= config.build_ruby || "4.0.6"
51
+ os = Everywhere::Config.os_of(target)
43
52
 
44
- preflight!(target)
53
+ preflight!(target, os)
45
54
 
46
55
  framework = config.remote? ? nil : Framework.detect(root_path)
47
56
  dist_dir = File.expand_path("dist", root_path)
48
57
 
49
- # 1. Produce (or reuse) the signed-for-dev .app via the build path. The
50
- # notarize step below re-signs it inside-out with the Developer ID.
58
+ # 1. Produce (or reuse) the build. macOS gets a dev-signed .app that the
59
+ # notarize step re-signs inside-out with the Developer ID; the mobile
60
+ # toolchains sign as they build, so their artifact is already final.
51
61
  unless skip_build
52
62
  UI.phase("Building #{app_name} for #{UI.target_label(target)}")
53
- Build.new.call(root: root, ruby: ruby, entry: entry, app_name: app_name, target: target, shell_dir: shell_dir, **)
63
+ Build.new.call(root: root, ruby: ruby, entry: entry, app_name: app_name, target: target,
64
+ shell_dir: shell_dir, **, **build_options(os, format, channel))
65
+ end
66
+
67
+ # 2. Sign/notarize (macOS) or locate what the mobile builder signed, then
68
+ # 3. record the signing facts and emit the receipt.
69
+ artifact, signing = case os
70
+ when "macos" then notarize!(dist_dir, root_path, env_file)
71
+ when "ios" then [find_artifact(dist_dir, os), ios_signing(dist_dir)]
72
+ else [find_artifact(dist_dir, os), android_signing]
73
+ end
74
+
75
+ receipt = Receipt.new(root: root_path, config: config, framework: framework, ruby: ruby,
76
+ target: target, channel: channel, shell_dir: resolve_shell_dir(shell_dir))
77
+ out = File.join(dist_dir, "release.json")
78
+ receipt.write(out, artifact: artifact, signing: signing)
79
+
80
+ summarize(out, artifact, signing, root_path)
81
+ end
82
+
83
+ private
84
+
85
+ def preflight!(target, os)
86
+ case os
87
+ when "macos"
88
+ UI.die!("`every release --target #{target}` runs on macOS only") unless Host.darwin?
89
+ UI.die!("signing engine missing at #{NOTARIZE}") unless File.exist?(NOTARIZE)
90
+ when "ios"
91
+ UI.die!("iOS releases need Xcode — `every release --target #{target}` runs on macOS only") unless Host.darwin?
92
+ when "android"
93
+ nil # Gradle signs and builds anywhere the Android SDK does — Linux included.
94
+ else
95
+ UI.die!("no release builder for #{target} — macos-*, ios-* and android-* are supported")
54
96
  end
97
+ end
98
+
55
99
 
100
+ # Only the flag that belongs to this os: `every build` rejects the other.
101
+ # iOS also needs the channel: a testflight/app_store release uploads to App
102
+ # Store Connect from the build host, which only the builder can do.
103
+ def build_options(os, format, channel)
104
+ case os
105
+ when "ios" then { device: true, channel: channel } # a release is always a device .ipa, never a simulator .app
106
+ when "android" then { format: format }
107
+ else {}
108
+ end
109
+ end
110
+
111
+ # macOS: sign (Developer ID + hardened runtime) → notarize → staple → verify,
112
+ # then read the signing facts back off the bundle.
113
+ def notarize!(dist_dir, root_path, env_file)
56
114
  app_dir = Dir[File.join(dist_dir, "*.app")].max_by { |d| File.mtime(d) }
57
115
  UI.die!("no .app found in #{dist_dir} — run without --skip-build first") unless app_dir
58
116
 
59
- # 2. Sign (Developer ID + hardened runtime) → notarize → staple → verify.
60
117
  env_file ||= discover_env_file(root_path)
61
118
  env = env_file ? { "ENV_FILE" => env_file } : {}
62
119
  UI.phase("Signing & notarizing #{File.basename(app_dir)}")
63
- UI.step("preparing #{File.basename(app_dir)}#{env_file ? " #{UI.dim("(env: #{rel(env_file, root_path)})")}" : ""}")
120
+ UI.step("preparing #{File.basename(app_dir)}")
121
+ # Named in full: these are the Apple credentials the notarization runs
122
+ # with, and "which file did that come from" is the first thing you ask
123
+ # when a build signs as the wrong team.
124
+ UI.detail("signing env: #{UI.short_path(File.expand_path(env_file))}") if env_file
64
125
  UI.warn("no signing env found — set APPLE_* or pass --env-file (notarize.sh will fail without it)") unless env_file || ENV["APPLE_SIGNING_IDENTITY"]
65
126
  log = "#{app_dir}.notarize.log"
66
127
  Shellout.run_logged!(env, ["/bin/bash", NOTARIZE, app_dir], log: log, filter: LogFilter.for(:notarize))
67
128
 
68
- # 3. Gather signing facts from the bundle itself, then emit the receipt.
69
129
  artifact = "#{app_dir.sub(/\.app\z/, "")}.zip" # notarize.sh re-zips the stapled app
70
130
  UI.die!("expected notarized zip #{artifact} not found") unless File.exist?(artifact)
71
- artifact = dashify(artifact)
72
131
 
73
- signing = gather_signing(app_dir, log)
74
- receipt = Receipt.new(root: root_path, config: config, framework: framework, ruby: ruby,
75
- target: target, channel: channel, shell_dir: resolve_shell_dir(shell_dir))
76
- out = File.join(dist_dir, "release.json")
77
- receipt.write(out, artifact: artifact, signing: signing)
132
+ [dashify(artifact), gather_signing(app_dir, log)]
133
+ end
78
134
 
79
- summarize(out, artifact, signing, root_path)
135
+ # The mobile artifact ships as-is — no zip, no rename: an .ipa/.apk/.aab is
136
+ # already the installable container the stores and devices expect.
137
+ def find_artifact(dist_dir, os)
138
+ patterns = ARTIFACTS.fetch(os)
139
+ found = patterns.filter_map { |p| Dir[File.join(dist_dir, p)].max_by { |f| File.mtime(f) } }.first
140
+ return found if found
141
+
142
+ UI.die!("no #{patterns.map { |p| p.delete("*") }.join(" or ")} found in #{dist_dir} — " \
143
+ "run without --skip-build first")
80
144
  end
81
145
 
82
- private
146
+ # Reaching this line IS the verification for iOS: xcodebuild -exportArchive
147
+ # refuses to emit an .ipa it couldn't sign with the requested profile, so
148
+ # there's nothing left to probe (and no spctl/stapler for a mobile bundle).
149
+ def ios_signing(dist_dir)
150
+ {
151
+ "signed" => true,
152
+ "signed_by" => ENV["EVERY_IOS_TEAM_ID"],
153
+ "verified" => true,
154
+ "platform" => {
155
+ "provisioning_profile" => ENV["EVERY_IOS_PROVISIONING_PROFILE"],
156
+ "distribution_method" => ENV["EVERY_IOS_EXPORT_METHOD"] || "app-store-connect",
157
+ "upload" => upload_bag(dist_dir)
158
+ }.compact
159
+ }
160
+ end
161
+
162
+ # dist/ios-upload.json is written by Builders::Ios only after the App Store
163
+ # Connect upload pass succeeded — so it's absent (and the key compacted
164
+ # away) for every non-store channel. This is the contract the Platform's
165
+ # store distributors read back out of the receipt.
166
+ def upload_bag(dist_dir)
167
+ path = File.join(dist_dir, "ios-upload.json")
168
+ return nil unless File.exist?(path)
83
169
 
84
- def preflight!(target)
85
- os = target.split("-", 2).first
86
- UI.die!("only the macos target is implemented (got #{target}); other builders are stubs") unless os == "macos"
87
- UI.die!("`every release` runs on macOS only") unless RUBY_PLATFORM.include?("darwin")
88
- UI.die!("signing engine missing at #{NOTARIZE}") unless File.exist?(NOTARIZE)
170
+ JSON.parse(File.read(path))
171
+ rescue JSON::ParserError
172
+ nil
173
+ end
174
+
175
+ # Gradle only signs when a keystore was handed to it; without one it still
176
+ # produces a (debug/unsigned) artifact, so the receipt has to say which.
177
+ def android_signing
178
+ keystore = ENV["EVERY_ANDROID_KEYSTORE"]
179
+ fingerprint = keystore_fingerprint(keystore)
180
+ {
181
+ "signed" => !fingerprint.nil?,
182
+ "signed_by" => fingerprint,
183
+ "verified" => !fingerprint.nil?,
184
+ "platform" => {
185
+ "signing_scheme" => fingerprint ? "v2" : "unsigned",
186
+ "keystore_fingerprint" => fingerprint
187
+ }.compact
188
+ }
189
+ end
190
+
191
+ # The file digest, not keytool's certificate fingerprint: it identifies the
192
+ # keystore that signed this build without shelling out to the JDK (which the
193
+ # runner may not have on PATH) or unlocking it with the store password.
194
+ def keystore_fingerprint(keystore)
195
+ return nil if keystore.nil? || keystore.empty? || !File.exist?(keystore)
196
+
197
+ "sha256:#{Digest::SHA256.file(keystore).hexdigest}"
89
198
  end
90
199
 
91
200
  # The distributable filename must be URL/CI-safe, so spaces become dashes
@@ -97,10 +206,11 @@ module Everywhere
97
206
  dashed
98
207
  end
99
208
 
100
- # A .env.release next to the app, then at the repo root above it.
209
+ # A .env.release next to the app and nowhere else. The parent directory
210
+ # used to be searched too, which meant a release could silently pick up
211
+ # Apple credentials from outside the project it was building.
101
212
  def discover_env_file(root_path)
102
- [ENV["ENV_FILE"], File.join(root_path, ".env.release"), File.join(root_path, "..", ".env.release")]
103
- .compact.find { |p| File.exist?(p) }
213
+ [ENV["ENV_FILE"], File.join(root_path, ".env.release")].compact.find { |p| File.exist?(p) }
104
214
  end
105
215
 
106
216
  def resolve_shell_dir(given)
@@ -138,15 +248,25 @@ module Everywhere
138
248
 
139
249
  def summarize(out, artifact, signing, root_path)
140
250
  UI.phase("Release ready 🎉")
141
- UI.success("#{rel(artifact, root_path)} #{UI.dim("(#{(File.size(artifact) / 1024.0 / 1024.0).round}MB — send this to your friends)")}")
251
+ UI.success("#{UI.rel_path(artifact, root_path)} #{UI.dim("(#{(File.size(artifact) / 1024.0 / 1024.0).round}MB — send this to your friends)")}")
142
252
  UI.substep("signed by #{UI.bold(signing["signed_by"] || "?")}")
143
- UI.substep("notarized #{UI.yn(signing.dig("platform", "notarized"))} · " \
144
- "stapled #{UI.yn(signing.dig("platform", "stapled"))} · " \
145
- "gatekeeper #{UI.yn(signing["verified"])}")
146
- UI.substep("receipt #{UI.dim(rel(out, root_path))}")
253
+ UI.substep(signing_line(signing["platform"] || {}, signing))
254
+ UI.substep("receipt #{UI.dim(UI.rel_path(out, root_path))}")
147
255
  end
148
256
 
149
- def rel(path, root_path) = UI.short_path(path.sub("#{root_path}/", "").sub("#{Dir.pwd}/", ""))
257
+ def signing_line(platform, signing)
258
+ if platform.key?("notarized")
259
+ "notarized #{UI.yn(platform["notarized"])} · stapled #{UI.yn(platform["stapled"])} · " \
260
+ "gatekeeper #{UI.yn(signing["verified"])}"
261
+ elsif platform.key?("distribution_method")
262
+ upload = platform["upload"]
263
+ "profile #{UI.bold(platform["provisioning_profile"] || "?")} · " \
264
+ "method #{platform["distribution_method"]}" \
265
+ "#{" · uploaded to App Store Connect (build #{upload["build_number"] || "?"})" if upload}"
266
+ else
267
+ "signing scheme #{UI.bold(platform["signing_scheme"] || "?")}"
268
+ end
269
+ end
150
270
  end
151
271
  end
152
272
  end
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "dry/cli"
4
+ require_relative "../config"
5
+ require_relative "../paths"
4
6
 
5
7
  module Everywhere
6
8
  module Commands
@@ -12,9 +14,9 @@ module Everywhere
12
14
  class ShellDir < Dry::CLI::Command
13
15
  desc "Print the path of the bundled shell (Tauri src-tauri, or the iOS template)"
14
16
 
15
- option :target, default: "macos-arm64", desc: "Build target (os or os-arch) selecting which shell"
17
+ option :target, default: "macos", desc: "Build target (os or os-arch) selecting which shell"
16
18
 
17
- def call(target: "macos-arm64", **)
19
+ def call(target: "macos", **)
18
20
  case Everywhere::Config.os_of(target)
19
21
  when "ios" then puts Everywhere::Paths.ios_dir!
20
22
  else puts Everywhere::Paths.shell_dir!
@@ -31,14 +31,22 @@ module Everywhere
31
31
  end
32
32
 
33
33
  UI.phase("Generating update signing key for #{bundle_id}")
34
- pair = Minisign.keygen(passphrase: passphrase ? prompt_passphrase : "")
34
+ secret = resolve_passphrase(passphrase)
35
+ pair = Minisign.keygen(passphrase: secret)
35
36
 
36
37
  require "fileutils"
37
38
  FileUtils.mkdir_p(keys_dir, mode: 0o700)
38
39
  File.write(sk_path, pair.secret_key, perm: 0o600)
39
40
  File.write(pub_path, pair.public_key, perm: 0o644)
41
+ # perm: only applies when the file is created — an overwrite (--force,
42
+ # or a key from an older version) keeps whatever mode it had.
43
+ File.chmod(0o600, sk_path)
40
44
 
41
45
  UI.ok("secret key #{UI.short_path(sk_path)} #{UI.dim("(key id #{pair.key_id_hex})")}")
46
+ if secret.empty?
47
+ UI.warn("this secret key is stored UNENCRYPTED — anyone who can read " \
48
+ "#{UI.short_path(sk_path)} can sign updates your users will install")
49
+ end
42
50
  UI.ok("public key #{UI.short_path(pub_path)}")
43
51
 
44
52
  UI.phase("Add the public key to config/everywhere.yml")
@@ -54,6 +62,22 @@ module Everywhere
54
62
 
55
63
  private
56
64
 
65
+ # --passphrase insists on one. Without the flag we still ask whenever
66
+ # there's a terminal to ask on — an unencrypted signing key is a real
67
+ # choice (CI has to have one), but it shouldn't be what you get by
68
+ # forgetting a flag. Empty answer = unencrypted, and it's warned about.
69
+ def resolve_passphrase(flag)
70
+ return prompt_passphrase if flag
71
+ return "" unless $stdin.tty?
72
+
73
+ require "io/console"
74
+ pass = $stdin.getpass("Passphrase (empty for an unencrypted key): ")
75
+ return "" if pass.empty?
76
+
77
+ UI.die!("passphrases didn't match") unless pass == $stdin.getpass("Passphrase (again): ")
78
+ pass
79
+ end
80
+
57
81
  def prompt_passphrase
58
82
  require "io/console"
59
83
  UI.die!("--passphrase needs an interactive terminal") unless $stdin.tty?
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Everywhere
4
+ class Config
5
+ module App
6
+ def name(target: nil)
7
+ resolved(target)["name"] || File.basename(File.expand_path(root)).split(/[-_]/).map(&:capitalize).join(" ")
8
+ end
9
+
10
+ # Reverse-DNS identifier: macOS bundle id, and the name of the per-user
11
+ # app-data directory on every platform. Set it explicitly and never change
12
+ # it — renaming moves users' data. A platform may override it (e.g. the App
13
+ # Store often wants `com.example.app.ios`).
14
+ def bundle_id(target: nil)
15
+ resolved(target)["bundle_id"] ||
16
+ "com.rubyeverywhere.#{name(target: target).downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-|-\z/, "")}"
17
+ end
18
+
19
+ # A bundle id isn't only an identifier: it's a path segment (Paths.*_work_dir,
20
+ # which the builders rm_rf, and the update-signing key on disk). The derived
21
+ # default is safe by construction; an explicit one is whatever the app repo
22
+ # says, so it's held to reverse-DNS characters and nothing else.
23
+ BUNDLE_ID = /\A[A-Za-z0-9][A-Za-z0-9.-]*\z/
24
+
25
+ def identity_errors
26
+ sections = [["app", app]] + platforms.map { |os, override| ["platforms.#{os}", override] }
27
+ sections.filter_map do |at, section|
28
+ next unless section.is_a?(Hash)
29
+
30
+ value = section["bundle_id"].to_s
31
+ next if value.empty? || (value.match?(BUNDLE_ID) && !value.include?(".."))
32
+
33
+ "#{at}.bundle_id #{value.inspect} is not a bundle id — reverse-DNS, " \
34
+ "letters/digits/./- only (like com.example.app)"
35
+ end
36
+ end
37
+
38
+ # App icon source: a PNG (ideally square, 1024px+). Explicit `app.icon`
39
+ # path relative to the app root, or icon.png at the root by convention.
40
+ def icon
41
+ if (explicit = app["icon"])
42
+ File.expand_path(explicit, root)
43
+ else
44
+ default = File.join(root, "icon.png")
45
+ File.exist?(default) ? default : nil
46
+ end
47
+ end
48
+
49
+ # Optional custom splash page (HTML file, path relative to the app root).
50
+ # Shown while the packaged server boots; the shell injects
51
+ # window.__EVERYWHERE_CONFIG__ so it can use the app's name and colors.
52
+ def splash
53
+ path = app["splash"]
54
+ File.expand_path(path, root) if path
55
+ end
56
+
57
+ def entry_path
58
+ path = app["entry_path"] || "/"
59
+ path.start_with?("/") ? path : "/#{path}"
60
+ end
61
+
62
+ # Marketing/display version of the app (CFBundleShortVersionString, and the
63
+ # `version` recorded in the build receipt). One shared `app.version` is the
64
+ # default for every target; a platform may override it when a store forces a
65
+ # different number. Defaults conservatively.
66
+ def version(target: nil)
67
+ resolved(target)["version"] || "0.1.0"
68
+ end
69
+
70
+ # The `build:` section — durable build knobs that were once `every build`
71
+ # flags (ruby, targets, capabilities). CLI flags still override per-run.
72
+ # See platform/docs/build-engine.md §2.
73
+ def build = @data.fetch("build", nil) || {}
74
+
75
+ # Ruby version to package. nil here means "let the CLI decide its default".
76
+ def build_ruby = build["ruby"]
77
+
78
+ # Desktop OS-integration capabilities the app declares (`build.capabilities`),
79
+ # as a plain list. Deliberately not called permissions: the top-level
80
+ # `permissions:` below is the mobile prompt declaration — a different shape
81
+ # for a different consumer — and while both were spelled "permissions" this
82
+ # accessor was silently redefined by that one and could never be read.
83
+ def build_capabilities = Array(build["capabilities"])
84
+
85
+ # Build targets as BARE os-arch strings — any ":<channel>" suffix is
86
+ # stripped. Callers treat these as filesystem/URL-safe identifiers
87
+ # (`every publish` derives S3 update-manifest key paths from the first one),
88
+ # so a suffix leaking through here would poison those paths. Use
89
+ # #build_target_specs when the channel matters.
90
+ def targets = build_target_specs.map { |t| t.to_s.split(":", 2).first }
91
+
92
+ # The raw `build.targets` entries, suffixes intact ("ios-arm64:testflight").
93
+ def build_target_specs = Array(build["targets"])
94
+
95
+ # "local" — the app is tebako-pressed and runs on-device (default)
96
+ # "remote" — thin shell around an already-deployed app: no press, no sidecar
97
+ def mode
98
+ app["mode"] || (remote_url ? "remote" : "local")
99
+ end
100
+
101
+ def remote? = mode == "remote"
102
+
103
+ def remote_url
104
+ @data.dig("remote", "url")&.chomp("/")
105
+ end
106
+
107
+ # Multi-instance apps (`remote.instances: true`): the mobile shell boots
108
+ # into remote.url — a hosted instance-picker page — and lets that page
109
+ # re-root the app onto a chosen instance via Everywhere.instance.set,
110
+ # persisted across launches until Everywhere.instance.clear. Opt-in
111
+ # because it lets page JS repoint the whole app: apps that aren't
112
+ # multi-instance shouldn't carry that surface.
113
+ def remote_instances?
114
+ @data.dig("remote", "instances") == true
115
+ end
116
+
117
+ def tint_color
118
+ normalize_color(appearance["tint_color"])
119
+ end
120
+
121
+ def background_color
122
+ normalize_color(appearance["background_color"])
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Everywhere
4
+ class Config
5
+ module Auth
6
+ # Third-party sign-in (Sign in with Apple / Google / GitHub / anything
7
+ # OmniAuth speaks). Providers refuse to run — or run badly — inside an app's
8
+ # web view, so the mobile shell hands these paths to an
9
+ # ASWebAuthenticationSession instead, and the gem bridges the resulting
10
+ # session back into the app (see AuthHandoff).
11
+ #
12
+ # auth:
13
+ # oauth_paths: # paths that begin a provider flow
14
+ # - ^/auth/ # unanchored regexes, like `rules:`
15
+ # scheme: com.example.app # callback scheme (default: the iOS bundle id)
16
+ # cookies: # which cookies cross back into the app
17
+ # except: ["_ga"] # (default: all of them)
18
+ #
19
+ # The app keeps its own auth: `auth:` declares nothing about providers,
20
+ # only which paths the shell must not open in its web view.
21
+ def auth = @data.fetch("auth", nil) || {}
22
+
23
+ # Paths that begin a third-party auth flow, as regex source strings matched
24
+ # against the request path. Declaring `auth:` at all opts in, defaulting to
25
+ # OmniAuth's `/auth/…` convention.
26
+ DEFAULT_OAUTH_PATHS = ["^/auth/"].freeze
27
+
28
+ def oauth_paths
29
+ return [] unless @data.key?("auth")
30
+
31
+ paths = Array(auth["oauth_paths"]).map { |p| p.to_s.strip }.reject(&:empty?)
32
+ paths.empty? ? DEFAULT_OAUTH_PATHS.dup : paths
33
+ end
34
+
35
+ def oauth? = !oauth_paths.empty?
36
+
37
+ # Whether a request path starts a provider flow. Both halves of the system
38
+ # ask this — the middleware per request, the shell per visit proposal — so
39
+ # the patterns are compiled the same way on both sides (unanchored regex).
40
+ def oauth_path?(path)
41
+ # Compiled once per Config, not once per request: the middleware calls
42
+ # this on every navigation, and the instance it holds lives until
43
+ # everywhere.yml changes on disk. An unparseable pattern matches nothing
44
+ # (auth_errors is what reports it).
45
+ @oauth_regexps ||= oauth_paths.filter_map do |pattern|
46
+ Regexp.new(pattern)
47
+ rescue RegexpError
48
+ nil
49
+ end
50
+ @oauth_regexps.any? { |regexp| regexp.match?(path.to_s) }
51
+ end
52
+
53
+ # The custom URL scheme ASWebAuthenticationSession returns through. Defaults
54
+ # to the iOS bundle id — the convention, and already unique per app. Only
55
+ # the characters RFC 3986 allows in a scheme.
56
+ URL_SCHEME = /\A[a-zA-Z][a-zA-Z0-9+.-]*\z/
57
+
58
+ def auth_scheme
59
+ explicit = auth["scheme"].to_s.strip
60
+ explicit.empty? ? bundle_id(target: "ios") : explicit
61
+ end
62
+
63
+ # Cookie names to carry from the auth browser back into the app's web view,
64
+ # filtered by an optional allow/deny list. Everything the flow set is
65
+ # carried by default: the gem can't know which cookie an app's auth library
66
+ # signs its session with.
67
+ def auth_cookie?(name)
68
+ cookies = auth["cookies"]
69
+ return true unless cookies.is_a?(Hash)
70
+
71
+ only = Array(cookies["only"]).map(&:to_s)
72
+ return only.include?(name.to_s) unless only.empty?
73
+
74
+ !Array(cookies["except"]).map(&:to_s).include?(name.to_s)
75
+ end
76
+
77
+ # How long a minted handoff token stays valid. It travels device-locally
78
+ # (browser sheet → shell → web view), so seconds are plenty.
79
+ def auth_token_ttl = [(auth["token_ttl"] || 60).to_i, 5].max
80
+
81
+ def auth_errors
82
+ return [] unless @data.key?("auth")
83
+
84
+ errors = oauth_paths.filter_map do |pattern|
85
+ Regexp.new(pattern)
86
+ nil
87
+ rescue RegexpError => e
88
+ "auth.oauth_paths: #{pattern.inspect} is not a valid pattern (#{e.message})"
89
+ end
90
+
91
+ unless auth_scheme.match?(URL_SCHEME)
92
+ errors << "auth.scheme #{auth_scheme.inspect} is not a URL scheme (letters, digits, +, -, .)"
93
+ end
94
+ errors
95
+ end
96
+
97
+ # The auth subset the shell needs: which visits to divert into the auth
98
+ # session, and the scheme to bring the answer back through. nil (absent from
99
+ # everywhere.json) when the app declares no `auth:` — a shell that can't
100
+ # divert anything is a shell with no new surface.
101
+ def auth_shell_hash
102
+ return nil unless oauth?
103
+
104
+ { "oauth_paths" => oauth_paths, "scheme" => auth_scheme }
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Everywhere
4
+ class Config
5
+ # The plumbing every other section reads through: the raw `app:` and
6
+ # `appearance:` hashes, the per-platform overrides that sit on top of them,
7
+ # and the two value coercions shared across sections. Included first, so
8
+ # everything below it can call these.
9
+ module Data
10
+ private
11
+
12
+ def app = @data.fetch("app", nil) || {}
13
+ def appearance = @data.fetch("appearance", nil) || {}
14
+
15
+ # YAML's `yes`/`on` already parse as true, so anything that reaches here
16
+ # still not a boolean is a genuine mistake rather than a spelling of one.
17
+ # Deliberately not an endless def: `def f(v) = v if cond` parses as
18
+ # `(def f(v) = v) if cond`, which would define nothing when cond is false.
19
+ def boolean_or_nil(value)
20
+ value if value == true || value == false
21
+ end
22
+
23
+ def android_target?(target) = self.class.os_of(target) == "android"
24
+
25
+ # Per-platform overrides, keyed by os (macos / ios / android / windows /
26
+ # linux). Each value overrides the matching `app:` keys for that platform.
27
+ def platforms = @data.fetch("platforms", nil) || {}
28
+
29
+ # The app-level keys with the target's platform override merged on top.
30
+ # Blank override values are dropped (`.compact`) so they don't erase a
31
+ # default. Without a target — or without an override for that os — this is
32
+ # just the plain `app:` hash.
33
+ def resolved(target)
34
+ override = target && platforms[self.class.os_of(target)]
35
+ override.is_a?(Hash) ? app.merge(override.compact) : app
36
+ end
37
+
38
+ def normalize_color(value)
39
+ case value
40
+ when String
41
+ { "light" => value, "dark" => value }
42
+ when Hash
43
+ light = value["light"] || value["dark"]
44
+ dark = value["dark"] || value["light"]
45
+ { "light" => light, "dark" => dark } if light
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end