ruby_everywhere 0.10.1 → 0.11.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 500e5331ac0f9b650cef020b9992c4f54bcff2ec08a37aa160da69c57468498a
4
- data.tar.gz: 78114f8b44040ad51a3d2e10b033f6e6df25f46ee0a91cfbd96e519987ae52fc
3
+ metadata.gz: a690caacf23100232c7d96fb2a5575d2408a475219ecd1290d6b03a6e69917e4
4
+ data.tar.gz: 98285827b8cac459773c44118c15f4447bbf5b3bbc182dbd49593c32e165a30c
5
5
  SHA512:
6
- metadata.gz: ecd03c6b0ee4823567da935d34696fa75925472b9e2cfb086cb8e00b80cc2e74b72d89c94b588ce807187a511fa12e2b4ce385105befd0e3fbd2f520efaadce1
7
- data.tar.gz: 683c8c90f049d8a7ae8718ad4ca12d4a02c7acfe639c01b76b38bcf9d7507ff37dfd7a7bf81ac6381dc1f990245f78ae83a2a99f6c1227488e2cc0105c58b6fb
6
+ metadata.gz: cce0faa307eb46f4d16ec00a1be4a5526168eceb4fb87934306afd009045ecc1e7e0c08009bef8bee2e89eb3b8f2407f57253baa9799478d74cd20c5d687049b
7
+ data.tar.gz: f4afd91f97283fb009f02bf2765d5c82be2d7fd6fd0b338929c01b09c13c4c01031179d7b780b9ebef0e5a405943a897f8754311b3c6b62ea9eb0143c56eb180
data/README.md CHANGED
@@ -231,8 +231,28 @@ there keep their clicks. `window.drag_height: 0` hands that back to you.
231
231
  Tabs and path rules ship two ways from that one source: baked into the app
232
232
  bundle for offline/first launch, and served live from
233
233
  `/everywhere/{ios,android}_v1.json`, so changing them deploys with your web app.
234
- `Everywhere.filter_tabs` lets you vary the tab bar per request — it shares the
235
- app's session, so signed-out users can get a different bar without a release.
234
+ `Everywhere.tabs` lets you build the tab bar per request — feature flags, plan
235
+ tiers, roles, anything your app can answer while serving a request:
236
+
237
+ ```ruby
238
+ # config/initializers/everywhere.rb
239
+ Rails.application.config.to_prepare do
240
+ Everywhere.current_user { |ctx| Session.find_by(id: ctx.cookies.signed[:session_id])&.user }
241
+
242
+ Everywhere.tabs do |tabs, ctx|
243
+ next [] unless ctx.current_user # [] hides the bar entirely
244
+
245
+ tabs.reject! { |tab| tab["title"] == "Labs" } unless Flipper.enabled?(:labs, ctx.current_user)
246
+ tabs << ctx.tab("Admin") if ctx.current_user.admin?
247
+ tabs
248
+ end
249
+ end
250
+ ```
251
+
252
+ The context carries `platform` (`"ios"`/`"android"`), `current_user`, and
253
+ `session`/`cookies` normalized across Rails and Rack. `ctx.tab("Admin")` pulls in
254
+ a tab declared `visible: false` in `everywhere.yml` — declared so the build
255
+ validates its icon, hidden so nothing ships it until this block says otherwise.
236
256
 
237
257
  ## The JavaScript bridge
238
258
 
@@ -907,7 +907,9 @@ export const Everywhere = {
907
907
  message: options.message,
908
908
  items,
909
909
  source: options.source
910
- }).then((id) => (id == null ? null : items.find((item) => String(item.id) === String(id)) || null))
910
+ // presentMenu answers with the chosen item's position, so this is an index
911
+ // lookup, not an id match — see the note above presentMenu for why.
912
+ }).then((index) => (index == null ? null : items[parseInt(index, 10)] || null))
911
913
  },
912
914
 
913
915
  // Refresh the native tab bar / navigation config now (mobile only; a no-op
@@ -1330,9 +1332,18 @@ async function wireBiometricToggles() {
1330
1332
  // Picks the native action sheet in the mobile shell and a DOM bottom sheet
1331
1333
  // (native.css .everywhere-sheet) everywhere else. Resolves the chosen item's
1332
1334
  // id, or null on cancel/dismiss.
1335
+ // Items travel under their POSITION, never the caller's own `id`. `id` is
1336
+ // optional, so something has to be minted for the items that omit one — and
1337
+ // minting from position while passing other items' ids straight through put two
1338
+ // namespaces in one string space: an explicit `{ id: 0 }` collided with the
1339
+ // synthesized id of whatever sat at index 0, so a tap the user got right mapped
1340
+ // back to the wrong item. Positions cannot collide with each other. The caller's
1341
+ // `id` never needs to cross the bridge at all — Everywhere.menu() resolves it
1342
+ // from the index once the answer returns. handleMenuTrigger has always done it
1343
+ // this way; this is the other caller catching up.
1333
1344
  function presentMenu({ title, message, items, source }) {
1334
1345
  const list = (items || []).map((item, i) => ({
1335
- id: item.id != null ? String(item.id) : String(i),
1346
+ id: String(i),
1336
1347
  title: item.title || "",
1337
1348
  image: item.image || null,
1338
1349
  style: item.style || "default",
@@ -1427,10 +1438,18 @@ function resolveNativeIcon(el, base) {
1427
1438
  return el.getAttribute(`${base}-${os}`) || el.getAttribute(base) || null
1428
1439
  }
1429
1440
 
1441
+ // A nav BUTTON's text is its label, so falling back to it is right.
1430
1442
  function navText(el) {
1431
1443
  return (el.getAttribute("data-everywhere-nav-title") || el.textContent || "").trim()
1432
1444
  }
1433
1445
 
1446
+ // A nav MENU's text is every item's label run together ("Edit Copy text
1447
+ // Delete") — never a title anyone meant. Only an explicit attribute counts;
1448
+ // both shells hide a blank one, which is what an untitled menu should get.
1449
+ function navMenuText(el) {
1450
+ return (el.getAttribute("data-everywhere-nav-title") || "").trim()
1451
+ }
1452
+
1434
1453
  function navSide(el) {
1435
1454
  return el.getAttribute("data-everywhere-nav-side") === "left" ? "left" : "right"
1436
1455
  }
@@ -1480,7 +1499,7 @@ function collectNavItems() {
1480
1499
  items.push({
1481
1500
  id,
1482
1501
  kind: "menu",
1483
- title: navText(menuEl),
1502
+ title: navMenuText(menuEl),
1484
1503
  image: resolveNativeIcon(menuEl, "data-everywhere-nav-icon") || "ellipsis.circle",
1485
1504
  side: navSide(menuEl),
1486
1505
  children
data/bridge/package.json CHANGED
@@ -8,6 +8,9 @@
8
8
  ".": "./everywhere/bridge.js",
9
9
  "./native.css": "./everywhere/native.css"
10
10
  },
11
+ "scripts": {
12
+ "test": "node --test \"test/**/*.test.mjs\""
13
+ },
11
14
  "files": ["everywhere/bridge.js", "everywhere/native.css", "README.md", "LICENSE"],
12
15
  "sideEffects": false,
13
16
  "keywords": ["rubyeverywhere", "tauri", "hotwire", "turbo", "rails", "desktop", "native", "bridge"],
@@ -24,10 +24,32 @@ module Everywhere
24
24
  # version complaint — so we check before Gradle ever starts.
25
25
  MINIMUM_JDK = 17
26
26
 
27
- # The API level the shell template compiles against. Named here (not in the
27
+ # What the shell template compiles against `compileSdk` in
28
+ # support/mobile/android/app/build.gradle.kts. Named here (not in the
28
29
  # builder) because `every doctor` has to print the sdkmanager line for it
29
30
  # long before a build is attempted.
30
- COMPILE_SDK = 35
31
+ COMPILE_SDK = 36
32
+
33
+ # `compileSdkMinor`, or nil for a level that has no minor version. Google
34
+ # minor-versions SDK platforms from API 37 on: the sdkmanager package and
35
+ # the platforms/ directory are both "android-37.0", never "android-37", and
36
+ # AGP wants the minor as its own DSL property. Nothing up to 36 carries a
37
+ # suffix, which is why this stayed invisible until the first 37 build.
38
+ COMPILE_SDK_MINOR = nil
39
+
40
+ # What the shell TARGETS — `targetSdk` in the same file, and a different
41
+ # question from COMPILE_SDK: compileSdk is which APIs the shell may call,
42
+ # targetSdk is which OS behaviour changes it opts into. Always a plain
43
+ # integer; AGP has no targetSdkMinor to go with compileSdkMinor.
44
+ TARGET_SDK = 36
45
+
46
+ # The lowest targetSdk Google Play accepts for a NEW upload today (36 as of
47
+ # 2026-08-31). Play raises this every August. Bump it when Google announces
48
+ # the next one: android_target_sdk_test then fails until the template
49
+ # catches up, which is the whole point of keeping it separate from
50
+ # TARGET_SDK. A stale targetSdk blocks new uploads only — apps already on
51
+ # the store keep working and keep updating.
52
+ PLAY_TARGET_SDK_FLOOR = 36
31
53
 
32
54
  # Android Studio's bundled JetBrains Runtime, per platform. Studio never
33
55
  # exports it, so these paths are the only way to find a JDK on a machine
@@ -273,17 +295,31 @@ module Everywhere
273
295
  .sort
274
296
  end
275
297
 
276
- # `platform?(35)` and `platform?("android-35")` both work callers spell it
277
- # whichever way reads better where they are.
278
- def platform?(api = COMPILE_SDK)
279
- name = api.to_s.start_with?("android-") ? api.to_s : "android-#{api}"
280
- installed_platforms.include?(name)
298
+ # How sdkmanager, AGP and the platforms/ directory all spell one platform,
299
+ # minor included. The single place that knows the naming rule.
300
+ def platform_hash(api = COMPILE_SDK, minor = COMPILE_SDK_MINOR)
301
+ minor.nil? ? "android-#{api}" : "android-#{api}.#{minor}"
281
302
  end
282
303
 
283
- def platform!(api = COMPILE_SDK)
304
+ def platform_name(api) = api.to_s.start_with?("android-") ? api.to_s : "android-#{api}"
305
+
306
+ # `platform?`, `platform?(37)` and `platform?("android-37.0")` all work.
307
+ # A full name has to match exactly, because that is the string AGP resolves
308
+ # and a minor is not interchangeable with its siblings — compileSdk 37 +
309
+ # compileSdkMinor 0 wants android-37.0 and nothing else. A bare major
310
+ # matches any minor of that level instead, since the caller named none.
311
+ # "android-36-ext18" satisfies neither: the separator is a dot.
312
+ def platform?(api = platform_hash)
313
+ name = platform_name(api)
314
+ return installed_platforms.include?(name) if api.to_s.start_with?("android-")
315
+
316
+ installed_platforms.any? { |p| p == name || p.start_with?("#{name}.") }
317
+ end
318
+
319
+ def platform!(api = platform_hash)
284
320
  return true if platform?(api)
285
321
 
286
- package = "platforms;android-#{api.to_s.delete_prefix("android-")}"
322
+ package = "platforms;#{platform_name(api)}"
287
323
  UI.die!("the Android SDK has no #{package} (installed: " \
288
324
  "#{installed_platforms.empty? ? "none" : installed_platforms.join(", ")}) — " \
289
325
  "#{install_hint(package)}")
@@ -317,10 +353,15 @@ module Everywhere
317
353
  # How to get a bootable AVD. The GUI route comes first because a machine
318
354
  # with Studio installed almost always has a device defined already, and
319
355
  # Device Manager also downloads the system image the CLI line assumes.
356
+ #
357
+ # System images are minor-versioned exactly like platforms
358
+ # (system-images;android-37.0;…), so they share platform_hash. Keyed to the
359
+ # compile level rather than TARGET_SDK because that one always names a
360
+ # package that exists; targetSdk carries no minor to resolve.
320
361
  def create_avd_hint
321
362
  "create one in Android Studio → Device Manager, or run: " \
322
363
  "#{(avdmanager || "avdmanager").shellescape} create avd -n Pixel_API_#{COMPILE_SDK} " \
323
- "-k \"system-images;android-#{COMPILE_SDK};google_apis;#{host_abi}\""
364
+ "-k \"system-images;#{platform_hash};google_apis;#{host_abi}\""
324
365
  end
325
366
 
326
367
  # The system image ABI that runs at native speed on this host — an x86_64
@@ -332,11 +332,16 @@ module Everywhere
332
332
 
333
333
  # --- identity -------------------------------------------------------------
334
334
 
335
- # app/build.gradle.kts reads these four values, so identity is stamped
335
+ # app/build.gradle.kts reads these five values, so identity is stamped
336
336
  # without ever editing a build script — the direct analogue of the iOS
337
337
  # App.xcconfig. The `everywhere.` prefix is the template's namespace:
338
338
  # its stamped(key) helper looks up "everywhere.$key", and the label
339
339
  # travels on to the frozen manifest as a placeholder.
340
+ #
341
+ # targetSdk is the one non-identity value here, and it is stamped rather
342
+ # than left to the template's default so `every build` records what the
343
+ # app actually shipped. Config#android_target_sdk is nil unless the app
344
+ # opted in, so the shell's own tested level is the default.
340
345
  def write_properties(work)
341
346
  File.write(File.join(work, "app", "everywhere.properties"), <<~PROPERTIES)
342
347
  # Written by `every build --target #{@target}` — do not edit; changes
@@ -345,6 +350,7 @@ module Everywhere
345
350
  everywhere.versionName=#{properties_value(@config.version(target: @target))}
346
351
  everywhere.versionCode=#{@version_code}
347
352
  everywhere.label=#{properties_value(name)}
353
+ everywhere.targetSdk=#{@config.android_target_sdk || AndroidSdk::TARGET_SDK}
348
354
  PROPERTIES
349
355
  end
350
356
 
@@ -617,7 +623,10 @@ module Everywhere
617
623
  # the outline — better to say so than to ship a lie.
618
624
  fillable = @config.native_android_icon_font_fetched?
619
625
 
620
- problems = @config.tabs_for("android").filter_map do |tab|
626
+ # include_hidden: a `visible: false` tab is served by an Everywhere.tabs
627
+ # block at runtime, so its icon has to exist in the font that ships even
628
+ # though nothing bakes it.
629
+ problems = @config.tabs_for("android", include_hidden: true).filter_map do |tab|
621
630
  icon = tab["icon"].to_s
622
631
  base = icon.sub(/\.fill\z/, "")
623
632
  if base != icon && !fillable
@@ -38,6 +38,15 @@ module Everywhere
38
38
  EXPORT_METHODS = %w[app-store-connect app-store ad-hoc development release-testing enterprise].freeze
39
39
  DEFAULT_EXPORT_METHOD = "app-store-connect"
40
40
 
41
+ # The entitlement capabilities a build can turn on, in the order their keys
42
+ # land in the generated App/App.entitlements. Each name pairs with a private
43
+ # `#<name>_entitlements` returning the keys that capability contributes, or
44
+ # {} when the app hasn't configured it — so adding one (push, App Groups,
45
+ # Sign in with Apple) is one entry here plus one method. Nothing downstream
46
+ # learns the new name: the plist writer only sees keys and values, and the
47
+ # xcconfig only asks whether ANY capability produced a key.
48
+ ENTITLEMENT_CAPABILITIES = %i[associated_domains].freeze
49
+
41
50
  # Distribution channels whose artifact has to reach App Store Connect: the
42
51
  # upload happens here, on the build host, because only this machine has the
43
52
  # archive and Xcode.
@@ -155,6 +164,10 @@ module Everywhere
155
164
  # Swift + generated registry). The pbxproj is never touched.
156
165
  def stamp!(work)
157
166
  UI.step("stamping shell #{UI.dim("(#{bundle_id} → #{UI.short_path(work)})")}")
167
+ # Entitlements first: the xcconfig's CODE_SIGN_ENTITLEMENTS only appears
168
+ # when a capability actually contributed a key, so the file that answers
169
+ # that question is resolved before the file that asks it.
170
+ write_entitlements(work)
158
171
  write_xcconfig(work)
159
172
  File.write(File.join(work, "App", "Resources", "everywhere.json"),
160
173
  @config.to_shell_json(target: @target))
@@ -165,40 +178,94 @@ module Everywhere
165
178
  write_launch_background(work)
166
179
  write_usage_strings(work)
167
180
  write_url_scheme(work)
168
- write_entitlements(work)
169
181
  write_app_icon(work)
170
182
  write_asset_catalog(work)
171
183
  write_extensions(work)
172
184
  write_native_packages(work)
173
185
  end
174
186
 
175
- # Associated Domains entitlement for deep linking (everywhere.yml
176
- # deep_linking:). Written only when configuredthe default build stays
177
- # entitlement-free, so the frozen template still compiles unchanged. The
178
- # matching CODE_SIGN_ENTITLEMENTS is set in the xcconfig (write_xcconfig).
179
- # applinks: for universal links, webcredentials: to match the association
180
- # file's webcredentials section (password autofill / associated credentials).
187
+ # App/App.entitlements: one dict holding every active capability's keys.
188
+ # Written only when something is in it with no capabilities configured
189
+ # the build stays entitlement-free, so the frozen template still compiles
190
+ # unsigned. The matching CODE_SIGN_ENTITLEMENTS is set in the xcconfig
191
+ # (write_xcconfig) under exactly the same condition.
181
192
  def write_entitlements(work)
182
- domains = @config.deep_linking_domains
183
- return unless @config.deep_linking? && domains.any?
193
+ keys = entitlement_keys
194
+ return if keys.empty?
184
195
 
185
- values = domains.flat_map { |host| ["applinks:#{host}", "webcredentials:#{host}"] }
186
- entries = values.map { |v| "\t\t<string>#{xml_escape(v)}</string>" }.join("\n")
187
- UI.step("stamping associated domains #{UI.dim("(#{domains.join(", ")})")}")
196
+ # Keys are ours, not the app's — but they take the same escape as the
197
+ # values so a capability that ever derives one from config can't open a
198
+ # hole the reviewer of that capability has to remember to close.
199
+ body = keys.flat_map { |key, value| ["\t<key>#{xml_escape(key)}</key>", *plist_value(value)] }
188
200
  File.write(File.join(work, "App", "App.entitlements"), <<~PLIST)
189
201
  <?xml version="1.0" encoding="UTF-8"?>
190
202
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
191
203
  <plist version="1.0">
192
204
  <dict>
193
- \t<key>com.apple.developer.associated-domains</key>
194
- \t<array>
195
- #{entries}
196
- \t</array>
205
+ #{body.join("\n")}
197
206
  </dict>
198
207
  </plist>
199
208
  PLIST
200
209
  end
201
210
 
211
+ # Every active capability's keys, merged into that one dict. Apple
212
+ # namespaces entitlement keys per capability, so two capabilities can't
213
+ # collide over a key in practice; a key whose value came out empty is
214
+ # dropped, because an entitlement that grants nothing is not worth making
215
+ # an otherwise entitlement-free build carry a file (and an empty <array>
216
+ # is something codesign has to sign for no reason).
217
+ #
218
+ # Memoized because both writers ask — write_entitlements for the file,
219
+ # write_xcconfig for whether to point at one — and a capability announces
220
+ # itself with UI.step as it resolves, so asking twice would say it twice.
221
+ #
222
+ # The registry is read through self.class so the list is a subclass's to
223
+ # extend rather than something the merge hardcodes.
224
+ def entitlement_keys
225
+ @entitlement_keys ||=
226
+ self.class::ENTITLEMENT_CAPABILITIES
227
+ .inject({}) { |keys, capability| keys.merge(send(:"#{capability}_entitlements")) }
228
+ .reject { |_key, value| value.respond_to?(:empty?) && value.empty? }
229
+ end
230
+
231
+ # Associated Domains for deep linking (everywhere.yml deep_linking:) — the
232
+ # first capability, and the shape the rest follow: return {} when the app
233
+ # hasn't asked for it, otherwise the keys it grants. applinks: for universal
234
+ # links, webcredentials: to match the association file's webcredentials
235
+ # section (password autofill / associated credentials).
236
+ def associated_domains_entitlements
237
+ domains = @config.deep_linking_domains
238
+ return {} unless @config.deep_linking? && domains.any?
239
+
240
+ UI.step("stamping associated domains #{UI.dim("(#{domains.join(", ")})")}")
241
+ { "com.apple.developer.associated-domains" =>
242
+ domains.flat_map { |host| ["applinks:#{host}", "webcredentials:#{host}"] } }
243
+ end
244
+
245
+ # One entitlement value as the plist XML lines that follow its <key>, one
246
+ # tab in. Apple's app entitlements come in three shapes and no more: arrays
247
+ # of strings (associated domains, App Groups, keychain access groups),
248
+ # plain strings (aps-environment, the iCloud container environment), and
249
+ # booleans (HealthKit, HomeKit, and most other on/off capabilities) — which
250
+ # is why the dict is flat and nothing here recurses.
251
+ #
252
+ # Every string goes through xml_escape because these values carry
253
+ # app-supplied hostnames and identifiers; an unescaped "<" is at best a
254
+ # plist codesign refuses to read, at worst extra entitlement keys of the
255
+ # app author's choosing. An unsupported type is a bug in a capability
256
+ # method rather than something a user can reach, so it raises here instead
257
+ # of quietly writing a <key> with no value under it.
258
+ def plist_value(value)
259
+ case value
260
+ when Array
261
+ ["\t<array>", *value.map { |item| "\t\t<string>#{xml_escape(item)}</string>" }, "\t</array>"]
262
+ when String then ["\t<string>#{xml_escape(value)}</string>"]
263
+ when true, false then ["\t<#{value}/>"]
264
+ else raise ArgumentError, "entitlement values must be a string, boolean or array of strings, " \
265
+ "got #{value.class}"
266
+ end
267
+ end
268
+
202
269
  # Native extensions: the app repo's native/ios/*.swift copied into the
203
270
  # template's Extensions/ folder, plus a generated registry naming what
204
271
  # everywhere.yml declares (native.ios). Extensions/ is a filesystem-
@@ -416,9 +483,11 @@ module Everywhere
416
483
  end
417
484
 
418
485
  def write_xcconfig(work)
419
- # Only point at an entitlements file when deep linking wrote one — an
420
- # empty CODE_SIGN_ENTITLEMENTS would break the signing step.
421
- entitlements = "CODE_SIGN_ENTITLEMENTS = App/App.entitlements\n" if @config.deep_linking? && @config.deep_linking_domains.any?
486
+ # Only point at an entitlements file when some capability wrote one — an
487
+ # empty CODE_SIGN_ENTITLEMENTS would break the signing step. Asked of the
488
+ # merged keys, not of any one capability, so a build that enables push
489
+ # and no deep linking gets the setting just the same.
490
+ entitlements = "CODE_SIGN_ENTITLEMENTS = App/App.entitlements\n" if entitlement_keys.any?
422
491
  File.write(File.join(work, "App", "App.xcconfig"), <<~XCCONFIG)
423
492
  // Written by `every build --target #{@target}` — do not edit; changes
424
493
  // belong in config/everywhere.yml.
@@ -237,7 +237,7 @@ module Everywhere
237
237
  ok &= fix_check(labelled("an Android virtual device", avds.first),
238
238
  sdk.create_avd_hint) { avds.any? }
239
239
 
240
- platform = "platforms;android-#{sdk::COMPILE_SDK}"
240
+ platform = "platforms;#{sdk.platform_hash}"
241
241
  ok &= fix_check(labelled(platform, listed(sdk.installed_platforms)),
242
242
  sdk.install_hint(platform)) { sdk.platform? }
243
243
 
@@ -206,6 +206,10 @@ module Everywhere
206
206
  # Mobile tab bar (iOS today, Android soon). Served live from
207
207
  # /everywhere/ios_v1.json too, so tab changes deploy with the app —
208
208
  # no app-store release. Icons: ios = SF Symbol name.
209
+ #
210
+ # Vary the bar per request with Everywhere.tabs in an initializer —
211
+ # signed-in only, feature flags, roles. visible: false declares a tab
212
+ # without shipping it, for that block to add back (ctx.tab("Admin")).
209
213
  # tabs:
210
214
  # - name: Home
211
215
  # path: /
@@ -215,6 +219,11 @@ module Everywhere
215
219
  # path: /settings
216
220
  # icons:
217
221
  # ios: gear
222
+ # - name: Admin
223
+ # path: /admin
224
+ # icons:
225
+ # ios: shield
226
+ # visible: false
218
227
 
219
228
  # iOS-specific knobs (and native Swift you compile into the shell —
220
229
  # see /docs/diy/native-extensions).
@@ -32,6 +32,50 @@ module Everywhere
32
32
 
33
33
  "#{at}.bundle_id #{value.inspect} is not a bundle id — reverse-DNS, " \
34
34
  "letters/digits/./- only (like com.example.app)"
35
+ end + android_target_sdk_errors
36
+ end
37
+
38
+ # `platforms.android.target_sdk` — the escape hatch for shipping ahead of
39
+ # a Google Play deadline that lands before our next release does. It is
40
+ # the ONLY SDK level an app may set, and deliberately not a general knob:
41
+ # targetSdk is a contract with the OS, not an app preference, and every
42
+ # behaviour change it opts into lands on the Kotlin in the frozen shell,
43
+ # which is only tested at AndroidSdk::TARGET_SDK. Setting it means "I
44
+ # accept untested OS behaviour".
45
+ #
46
+ # compileSdk stays unexposed on purpose: from API 37 on it needs a
47
+ # matching compileSdkMinor and an AGP that knows the level, so moving it
48
+ # is a template change, never a config one.
49
+ #
50
+ # nil when unset — the builder substitutes AndroidSdk::TARGET_SDK, so the
51
+ # default lives in one place rather than being duplicated here.
52
+ def android_target_sdk
53
+ raw = android_platform["target_sdk"]
54
+ Integer(raw.to_s, exception: false) unless raw.nil?
55
+ end
56
+
57
+ # Raising it is the supported direction. Lowering is refused: the shipped
58
+ # Kotlin assumes the behaviour of the level it was built for, and a
59
+ # target under Play's floor is rejected at upload — the exact failure
60
+ # AndroidSdk::PLAY_TARGET_SDK_FLOOR exists to catch. Nothing caps the top
61
+ # end, because targeting a level above compileSdk is the whole point (and
62
+ # AGP allows it: the manifest claims the level, the code compiled against
63
+ # ours).
64
+ def android_target_sdk_errors
65
+ raw = android_platform["target_sdk"]
66
+ return [] if raw.nil?
67
+
68
+ value = android_target_sdk
69
+ tested = AndroidSdk::TARGET_SDK
70
+
71
+ if value.nil?
72
+ ["platforms.android.target_sdk #{raw.inspect} is not an API level — " \
73
+ "a whole number like #{tested}"]
74
+ elsif value < tested
75
+ ["platforms.android.target_sdk #{value} is below the shell's tested level " \
76
+ "(#{tested}) — this option only raises it, to ship ahead of a Play deadline"]
77
+ else
78
+ []
35
79
  end
36
80
  end
37
81
 
@@ -22,6 +22,14 @@ module Everywhere
22
22
 
23
23
  def android_target?(target) = self.class.os_of(target) == "android"
24
24
 
25
+ # `platforms.android` as a Hash, whatever the YAML actually held — a
26
+ # scalar there would make Hash#dig raise instead of reporting a config
27
+ # error.
28
+ def android_platform
29
+ value = platforms["android"]
30
+ value.is_a?(Hash) ? value : {}
31
+ end
32
+
25
33
  # Per-platform overrides, keyed by os (macos / ios / android / windows /
26
34
  # linux). Each value overrides the matching `app:` keys for that platform.
27
35
  def platforms = @data.fetch("platforms", nil) || {}
@@ -12,11 +12,20 @@ module Everywhere
12
12
  # icons:
13
13
  # ios: hammer
14
14
  # android: build
15
+ # - name: Admin
16
+ # path: /admin
17
+ # visible: false # declared, never shown until a hook adds it
15
18
  #
16
19
  # Tabs ship two ways from the same source: baked into the app's bundled
17
20
  # path-configuration.json at build time (offline/first-launch), and served
18
21
  # live from /everywhere/<platform>_v1.json (MobileConfigEndpoint) so tab
19
22
  # changes deploy with the web app — no app-store release.
23
+ #
24
+ # `visible: false` declares a tab without showing it anywhere: not baked,
25
+ # not served, but validated. It's the honest home for a tab an
26
+ # Everywhere.tabs block adds per request (`ctx.tab("Admin")`) — an
27
+ # undeclared tab renders blank on Android when its icon name is a typo,
28
+ # because the build only checks names it can see.
20
29
  def tabs
21
30
  entries = @data["tabs"]
22
31
  return [] unless entries.is_a?(Array)
@@ -26,14 +35,19 @@ module Everywhere
26
35
 
27
36
  path = tab["path"].start_with?("/") ? tab["path"] : "/#{tab["path"]}"
28
37
  { "name" => tab["name"], "path" => path,
29
- "icon" => tab["icon"], "icons" => (tab["icons"] if tab["icons"].is_a?(Hash)) }.compact
38
+ "icon" => tab["icon"], "icons" => (tab["icons"] if tab["icons"].is_a?(Hash)),
39
+ "visible" => (false if tab["visible"] == false) }.compact
30
40
  end
31
41
  end
32
42
 
33
43
  # Tabs with the icon resolved for one platform: icons.<os>, then the
34
- # shared icon, then a safe default.
35
- def tabs_for(os)
36
- tabs.map do |tab|
44
+ # shared icon, then a safe default. Hidden tabs are left out unless asked
45
+ # for — the build asks, to validate every icon name it can see, and so
46
+ # does RequestContext#tab, which exists to hand one back.
47
+ def tabs_for(os, include_hidden: false)
48
+ entries = tabs
49
+ entries = entries.reject { |tab| tab["visible"] == false } unless include_hidden
50
+ entries.map do |tab|
37
51
  { "title" => tab["name"], "path" => tab["path"],
38
52
  "icon" => tab.dig("icons", os.to_s) || tab["icon"] || DEFAULT_TAB_ICONS[os.to_s] }.compact
39
53
  end
@@ -4,6 +4,7 @@ require "yaml"
4
4
  require_relative "error"
5
5
  require_relative "ui"
6
6
  require_relative "native_platform"
7
+ require_relative "android_sdk" # AndroidSdk::TARGET_SDK bounds platforms.android.target_sdk
7
8
  require_relative "shell_pages"
8
9
  require_relative "tab_filter"
9
10
  require_relative "config/data"
@@ -76,7 +76,7 @@ module Everywhere
76
76
 
77
77
  os = match[1]
78
78
  config = Config.load(@root)
79
- tabs = Everywhere.resolve_tabs(config.tabs_for(os), request_for(env))
79
+ tabs = Everywhere.resolve_tabs(config.tabs_for(os), request_for(env), platform: os, config: config)
80
80
  tabs = Jump.exit_tabs(config, os, tabs) if Jump.enabled?
81
81
  json(config.path_configuration_json(os, tabs: tabs))
82
82
  end
@@ -31,7 +31,7 @@ module Everywhere
31
31
  response.headers["cache-control"] = "no-store"
32
32
 
33
33
  os = params[:platform_v1].delete_suffix("_v1")
34
- tabs = Everywhere.resolve_tabs(config.tabs_for(os), request)
34
+ tabs = Everywhere.resolve_tabs(config.tabs_for(os), request, platform: os, config: config)
35
35
  tabs = Jump.exit_tabs(config, os, tabs) if Jump.enabled?
36
36
  render json: config.path_configuration_hash(os, tabs: tabs)
37
37
  end
@@ -211,6 +211,11 @@ module Everywhere
211
211
  # <%= everywhere_menu_item "Share", share_path, icons: { ios: "square.and.arrow.up" } %>
212
212
  # <%= everywhere_menu_item "Delete", note_path(@note), method: :delete, style: "destructive" %>
213
213
  # <% end %>
214
+ #
215
+ # `title` heads the native menu (a UIMenu title on iOS, the sheet's header
216
+ # on Android); leave it off for a bare ⋯ and the shells render no header.
217
+ # Unlike a nav button, a menu never borrows its text from the page — that
218
+ # text is its items' labels.
214
219
  def everywhere_nav_menu(title = nil, icon: nil, icons: nil, side: "right",
215
220
  css_class: nil, id: nil, &block)
216
221
  content = _everywhere_capture(&block)
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "error"
4
+
5
+ module Everywhere
6
+ # What a per-request hook — the tab bar block, the current-user resolver —
7
+ # gets handed instead of a bare request.
8
+ #
9
+ # It exists because the raw request isn't the same object on every path: Rails
10
+ # apps reach the mobile config endpoint through MobileConfigsController with an
11
+ # ActionDispatch::Request (signed/encrypted cookie jar), Sinatra and Hanami
12
+ # apps through MobileConfigEndpoint with a Rack::Request (plain cookie hash).
13
+ # A block written against one used to break on the other; #session and
14
+ # #cookies here read the same on both.
15
+ class RequestContext
16
+ # The framework-native request, for anything this class doesn't wrap
17
+ # (Warden lives at `request.env["warden"]`, for one).
18
+ attr_reader :request
19
+
20
+ # "ios" or "android" — which shell is asking. nil outside the mobile config
21
+ # endpoint.
22
+ attr_reader :platform
23
+
24
+ # The app's Everywhere::Config. nil outside the mobile config endpoint.
25
+ attr_reader :config
26
+
27
+ def initialize(request, platform: nil, config: nil)
28
+ @request = request
29
+ @platform = platform&.to_s
30
+ @config = config
31
+ end
32
+
33
+ def ios? = platform == "ios"
34
+ def android? = platform == "android"
35
+
36
+ # The session hash, or {} when the app runs no session middleware (and on
37
+ # the bare-env fallback, where `request` is a Hash rather than a Request).
38
+ def session
39
+ return @session if defined?(@session)
40
+
41
+ @session = (request.session if request.respond_to?(:session)) || {}
42
+ end
43
+
44
+ # Cookies. Rails' jar when the request carries one, so `cookies.signed[…]`
45
+ # and `cookies.encrypted[…]` work; Rack's name => value hash otherwise. Both
46
+ # answer `cookies[:name]`, which is the common case.
47
+ def cookies
48
+ return @cookies if defined?(@cookies)
49
+
50
+ @cookies =
51
+ if request.respond_to?(:cookie_jar) then request.cookie_jar
52
+ elsif request.respond_to?(:cookies) then request.cookies
53
+ else {}
54
+ end
55
+ end
56
+
57
+ # Whoever `Everywhere.current_user` resolves for this request — nil when no
58
+ # resolver is registered, so a hook that branches on it degrades to "signed
59
+ # out" rather than raising. Memoized: resolvers hit the database, and the
60
+ # tab block is free to ask several times.
61
+ def current_user
62
+ return @current_user if defined?(@current_user)
63
+
64
+ @current_user = Everywhere.resolve_current_user(self)
65
+ end
66
+
67
+ def signed_in? = !current_user.nil?
68
+
69
+ # A tab declared in config/everywhere.yml, by name, resolved for this
70
+ # platform — including `visible: false` ones, which is the point: declare a
71
+ # tab so the build validates its icon and bakes nothing, then add it back
72
+ # here for the users who should see it.
73
+ #
74
+ # Everywhere.tabs do |tabs, ctx|
75
+ # ctx.current_user.admin? ? tabs + [ctx.tab("Admin")] : tabs
76
+ # end
77
+ #
78
+ # Raises on an unknown name rather than returning nil: a typo would
79
+ # otherwise drop the tab silently, and this fails in the endpoint where the
80
+ # message can name it.
81
+ def tab(name)
82
+ unless config && platform
83
+ raise Error, "RequestContext#tab needs the app config — it's available " \
84
+ "inside an Everywhere.tabs block, not to a standalone context"
85
+ end
86
+
87
+ declared = config.tabs_for(platform, include_hidden: true)
88
+ wanted = name.to_s
89
+ found = declared.find { |tab| tab["title"] == wanted } ||
90
+ declared.find { |tab| tab["title"].to_s.casecmp?(wanted) }
91
+ return found if found
92
+
93
+ raise Error, "no tab named #{wanted.inspect} in config/everywhere.yml — " \
94
+ "declared: #{declared.map { |tab| tab["title"] }.inspect}"
95
+ end
96
+ end
97
+ end
@@ -1,36 +1,95 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "request_context"
4
+
3
5
  module Everywhere
4
6
  class << self
5
- # Register a per-request filter for the mobile tab bar. The block receives
6
- # the resolved tab list (`[{ "title" =>, "path" =>, "icon" => }, …]`) and
7
- # the current request, and returns the subset to show return `[]` to hide
8
- # the tab bar entirely (the shell falls back to single-screen navigation).
7
+ # Register the per-request tab bar hook. The block receives the tabs
8
+ # config/everywhere.yml declares (`[{ "title" =>, "path" =>, "icon" => }, …]`,
9
+ # already resolved for the asking platform) and an Everywhere::RequestContext,
10
+ # and returns the list to serve return `[]` to hide the tab bar entirely
11
+ # (the shell falls back to single-screen navigation).
9
12
  #
10
- # It runs inside the mobile config endpoint, which shares the app's session
11
- # and cookies, so it can branch on auth or any request state:
13
+ # The list it returns is the list, so this adds and reorders as well as
14
+ # hides. Anything the app can answer during a request can decide it:
12
15
  #
13
16
  # # config/initializers/everywhere.rb
14
- # Everywhere.filter_tabs do |tabs, request|
15
- # request.session[:user_id] ? tabs : []
17
+ # Rails.application.config.to_prepare do
18
+ # Everywhere.tabs do |tabs, ctx|
19
+ # next [] unless ctx.current_user
20
+ #
21
+ # tabs = tabs.reject { |tab| tab["title"] == "Labs" } unless
22
+ # Flipper.enabled?(:labs, ctx.current_user)
23
+ # tabs << ctx.tab("Admin") if ctx.current_user.admin?
24
+ # tabs
25
+ # end
16
26
  # end
17
27
  #
28
+ # `ctx.tab("Admin")` pulls in a tab declared `visible: false` — see
29
+ # RequestContext#tab. `ctx.current_user` needs a resolver; see #current_user.
30
+ #
18
31
  # Live like the rest of the config: the shell re-reads it on launch and on
19
32
  # every foreground, so a sign-in shows the tabs on next foreground — no
20
- # rebuild, no app-store release.
21
- def filter_tabs(&block)
33
+ # rebuild, no app-store release. Wrap the registration in `to_prepare` so a
34
+ # reload in development doesn't leave the block closed over stale constants.
35
+ def tabs(&block)
36
+ raise ArgumentError, "Everywhere.tabs needs a block — Everywhere.tabs { |tabs, ctx| … }" unless block
37
+
22
38
  @tabs_filter = block
23
39
  end
24
40
 
25
- # Apply the registered filter (identity when none is set). Always returns
41
+ # The original filter-only form, still supported: the block takes the
42
+ # framework-native request rather than a context, and can only subset the
43
+ # list it's given. #tabs is the one to write today.
44
+ def filter_tabs(&block)
45
+ raise ArgumentError, "Everywhere.filter_tabs needs a block" unless block
46
+
47
+ @tabs_filter = ->(tabs, context) { block.call(tabs, context.request) }
48
+ end
49
+
50
+ # Register how this app identifies the requester, once, for every hook that
51
+ # asks (`ctx.current_user`). The mobile config endpoint deliberately doesn't
52
+ # inherit ApplicationController — its before_actions would lock the shells
53
+ # out — so it can't reach `Current` or a `current_user` helper, and this is
54
+ # where the app closes that gap:
55
+ #
56
+ # # config/initializers/everywhere.rb
57
+ # Rails.application.config.to_prepare do
58
+ # Everywhere.current_user do |ctx|
59
+ # session_id = ctx.cookies.signed[:session_id]
60
+ # Session.find_by(id: session_id)&.user if session_id
61
+ # end
62
+ # end
63
+ #
64
+ # Devise and anything else on Warden need no cookie work at all:
65
+ #
66
+ # Everywhere.current_user { |ctx| ctx.request.env["warden"]&.user }
67
+ #
68
+ # Return nil for a signed-out request. Runs at most once per request.
69
+ def current_user(&block)
70
+ unless block
71
+ raise ArgumentError, "Everywhere.current_user needs a block — it registers the resolver " \
72
+ "(ask a RequestContext for the user itself: ctx.current_user)"
73
+ end
74
+
75
+ @current_user_resolver = block
76
+ end
77
+
78
+ # Apply the registered tab hook (identity when none is set). Always returns
26
79
  # an Array so a stray nil/scalar from a block can't break serialization.
27
- def resolve_tabs(tabs, request)
80
+ def resolve_tabs(tabs, request, platform: nil, config: nil)
28
81
  return tabs unless @tabs_filter
29
82
 
30
- Array(@tabs_filter.call(tabs, request))
83
+ context = RequestContext.new(request, platform: platform, config: config)
84
+ Array(@tabs_filter.call(tabs, context))
85
+ end
86
+
87
+ # nil when no resolver is registered — hooks treat that as signed out.
88
+ def resolve_current_user(context)
89
+ @current_user_resolver&.call(context)
31
90
  end
32
91
 
33
- # Test/reset hook.
34
- attr_writer :tabs_filter
92
+ # Test/reset hooks.
93
+ attr_writer :tabs_filter, :current_user_resolver
35
94
  end
36
95
  end
@@ -3,7 +3,7 @@
3
3
  require "json"
4
4
 
5
5
  module Everywhere
6
- VERSION = "0.10.1"
6
+ VERSION = "0.11.0"
7
7
 
8
8
  # Version of the @rubyeverywhere/bridge JS this gem ships. bridge/ in the
9
9
  # gem IS the npm package (served to Rails apps by Everywhere::Engine,
@@ -91,8 +91,11 @@ phone shell rebuilds its web views and re-fetches its tab bar:
91
91
  redirect_to everywhere_auth_redirect(after_authentication_url)
92
92
  ```
93
93
 
94
- **Tab bar, per request.** `Everywhere.filter_tabs { |tabs, request| … }` in an initializer
95
- varies the mobile tab bar — it shares the app's session; return `[]` to hide the bar.
94
+ **Tab bar, per request.** `Everywhere.tabs { |tabs, ctx| … }` in an initializer (inside
95
+ `to_prepare`) builds the mobile tab bar — return the list to serve, or `[]` to hide the bar.
96
+ `ctx` carries `platform`, `session`/`cookies`, and `current_user` (register the lookup once
97
+ with `Everywhere.current_user { |ctx| … }`). `ctx.tab("Name")` pulls in a tab declared
98
+ `visible: false` in `everywhere.yml`.
96
99
 
97
100
  Styling helpers live in `everywhere/native.css`, including `.everywhere-titlebar-inset` for
98
101
  `window.title_bar: overlay`.
@@ -15,9 +15,11 @@ frozen. When `every build --target android-*` stamps a copy, it overwrites
15
15
  **exactly** these files and nothing else:
16
16
 
17
17
  1. `app/everywhere.properties` — applicationId, versionName, versionCode, app
18
- label. The direct analogue of iOS's `App.xcconfig`: identity is stamped
19
- without ever touching a build script. `app/build.gradle.kts` reads it at
20
- configuration time.
18
+ label, targetSdk. The direct analogue of iOS's `App.xcconfig`: identity is
19
+ stamped without ever touching a build script. `app/build.gradle.kts` reads
20
+ it at configuration time. targetSdk is the one non-identity value, stamped
21
+ so `platforms.android.target_sdk` can raise it ahead of a Play deadline
22
+ without editing the frozen script.
21
23
  2. `app/src/main/assets/everywhere.json` — runtime config (remote URL, entry
22
24
  path, colours, permissions, auth, universal-link hosts);
23
25
  `Config#to_shell_json`.
@@ -103,7 +105,8 @@ no `+`, no version catalog. Moving a pin is a deliberate edit here.
103
105
  | Android Gradle Plugin | 8.13.2 |
104
106
  | Kotlin / kotlinx.serialization plugin | 2.3.0 |
105
107
  | `dev.hotwire:core`, `dev.hotwire:navigation-fragments` | 1.3.0 |
106
- | `compileSdk` / `targetSdk` | 35 |
108
+ | `compileSdk` | 36 — frozen; from API 37 on it needs `compileSdkMinor` too |
109
+ | `targetSdk` | 36 — Play's floor for new uploads from 2026-08-31; stamped, so `platforms.android.target_sdk` can raise it |
107
110
  | `minSdk` | 28 — Hotwire Native Android's floor, not negotiable |
108
111
  | Java toolchain / JVM target | 17 |
109
112
 
@@ -32,7 +32,11 @@ android {
32
32
  // deliberately NOT the applicationId: apps rename their bundle id freely,
33
33
  // and a namespace change would move every generated class.
34
34
  namespace = "com.rubyeverywhere.shell"
35
- compileSdk = 35
35
+ // Lockstep with Everywhere::AndroidSdk::COMPILE_SDK, which is what `every
36
+ // doctor` preflights and what names the sdkmanager package. From API 37 on
37
+ // a platform also needs `compileSdkMinor` (android-37.0, not android-37) —
38
+ // that's AndroidSdk::COMPILE_SDK_MINOR. android_target_sdk_test pins both.
39
+ compileSdk = 36
36
40
 
37
41
  defaultConfig {
38
42
  applicationId = stamped("applicationId", "com.rubyeverywhere.app")
@@ -43,7 +47,17 @@ android {
43
47
  // It also clears API 26 for Typeface.Builder.setFontVariationSettings,
44
48
  // which IconFont needs for the Material Symbols FILL axis.
45
49
  minSdk = 28
46
- targetSdk = 35
50
+ // Which OS behaviour changes the shell opts into, and what Play gates
51
+ // new uploads on (36 from 2026-08-31). The default below is lockstep
52
+ // with Everywhere::AndroidSdk::TARGET_SDK, which must not fall below
53
+ // PLAY_TARGET_SDK_FLOOR; android_target_sdk_test pins the pair.
54
+ //
55
+ // Stamped, unlike compileSdk, so an app can raise it via
56
+ // platforms.android.target_sdk to ship ahead of a Play deadline that
57
+ // lands before our next release. It only goes up, and it means
58
+ // accepting OS behaviour this template hasn't been tested against —
59
+ // the behaviour changes land on the Kotlin here, not on the app.
60
+ targetSdk = stamped("targetSdk", "36").toInt()
47
61
 
48
62
  // Consumed by AndroidManifest.xml. The label is a placeholder so the
49
63
  // manifest itself stays frozen when the app is renamed.
@@ -6,3 +6,6 @@ everywhere.applicationId=com.rubyeverywhere.app
6
6
  everywhere.versionName=0.1.0
7
7
  everywhere.versionCode=1
8
8
  everywhere.label=RubyEverywhere
9
+ # Not identity: the shell's tested targetSdk, which an app may raise via
10
+ # platforms.android.target_sdk. build.gradle.kts defaults to the same number.
11
+ everywhere.targetSdk=36
@@ -11,12 +11,6 @@
11
11
  EBE000000000000000000032 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBE000000000000000000014 /* SceneDelegate.swift */; };
12
12
  EBE000000000000000000033 /* EverywhereConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBE000000000000000000015 /* EverywhereConfig.swift */; };
13
13
  EBE000000000000000000034 /* ErrorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBE000000000000000000016 /* ErrorViewController.swift */; };
14
- EBE000000000000000000035 /* NotificationComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBE000000000000000000017 /* NotificationComponent.swift */; };
15
- EBE000000000000000000044 /* HapticsComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBE000000000000000000043 /* HapticsComponent.swift */; };
16
- EBE000000000000000000046 /* PermissionsComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBE000000000000000000045 /* PermissionsComponent.swift */; };
17
- EBE000000000000000000058 /* BiometricsComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBE000000000000000000057 /* BiometricsComponent.swift */; };
18
- EBE000000000000000000060 /* StorageComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBE000000000000000000059 /* StorageComponent.swift */; };
19
- EBE000000000000000000068 /* MenuComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBE000000000000000000067 /* MenuComponent.swift */; };
20
14
  EBE000000000000000000063 /* EverywhereHost.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBE000000000000000000062 /* EverywhereHost.swift */; };
21
15
  EBE000000000000000000070 /* AuthFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBE000000000000000000069 /* AuthFlow.swift */; };
22
16
  EBE000000000000000000036 /* everywhere.json in Resources */ = {isa = PBXBuildFile; fileRef = EBE000000000000000000018 /* everywhere.json */; };
@@ -35,12 +29,6 @@
35
29
  EBE000000000000000000014 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
36
30
  EBE000000000000000000015 /* EverywhereConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EverywhereConfig.swift; sourceTree = "<group>"; };
37
31
  EBE000000000000000000016 /* ErrorViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorViewController.swift; sourceTree = "<group>"; };
38
- EBE000000000000000000017 /* NotificationComponent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationComponent.swift; sourceTree = "<group>"; };
39
- EBE000000000000000000043 /* HapticsComponent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HapticsComponent.swift; sourceTree = "<group>"; };
40
- EBE000000000000000000045 /* PermissionsComponent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionsComponent.swift; sourceTree = "<group>"; };
41
- EBE000000000000000000057 /* BiometricsComponent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BiometricsComponent.swift; sourceTree = "<group>"; };
42
- EBE000000000000000000059 /* StorageComponent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageComponent.swift; sourceTree = "<group>"; };
43
- EBE000000000000000000067 /* MenuComponent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuComponent.swift; sourceTree = "<group>"; };
44
32
  EBE000000000000000000018 /* everywhere.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = everywhere.json; sourceTree = "<group>"; };
45
33
  EBE000000000000000000019 /* path-configuration.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "path-configuration.json"; sourceTree = "<group>"; };
46
34
  EBE000000000000000000020 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
@@ -50,6 +38,7 @@
50
38
  /* End PBXFileReference section */
51
39
 
52
40
  /* Begin PBXFileSystemSynchronizedRootGroup section */
41
+ EBE000000000000000000005 /* Bridge */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Bridge; sourceTree = "<group>"; };
53
42
  EBE000000000000000000061 /* Extensions */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Extensions; sourceTree = "<group>"; };
54
43
  /* End PBXFileSystemSynchronizedRootGroup section */
55
44
 
@@ -102,19 +91,6 @@
102
91
  path = App;
103
92
  sourceTree = "<group>";
104
93
  };
105
- EBE000000000000000000005 /* Bridge */ = {
106
- isa = PBXGroup;
107
- children = (
108
- EBE000000000000000000017 /* NotificationComponent.swift */,
109
- EBE000000000000000000043 /* HapticsComponent.swift */,
110
- EBE000000000000000000045 /* PermissionsComponent.swift */,
111
- EBE000000000000000000057 /* BiometricsComponent.swift */,
112
- EBE000000000000000000059 /* StorageComponent.swift */,
113
- EBE000000000000000000067 /* MenuComponent.swift */,
114
- );
115
- path = Bridge;
116
- sourceTree = "<group>";
117
- };
118
94
  EBE000000000000000000023 /* Resources */ = {
119
95
  isa = PBXGroup;
120
96
  children = (
@@ -140,6 +116,7 @@
140
116
  dependencies = (
141
117
  );
142
118
  fileSystemSynchronizedGroups = (
119
+ EBE000000000000000000005 /* Bridge */,
143
120
  EBE000000000000000000061 /* Extensions */,
144
121
  );
145
122
  name = App;
@@ -211,12 +188,6 @@
211
188
  EBE000000000000000000032 /* SceneDelegate.swift in Sources */,
212
189
  EBE000000000000000000033 /* EverywhereConfig.swift in Sources */,
213
190
  EBE000000000000000000034 /* ErrorViewController.swift in Sources */,
214
- EBE000000000000000000035 /* NotificationComponent.swift in Sources */,
215
- EBE000000000000000000044 /* HapticsComponent.swift in Sources */,
216
- EBE000000000000000000046 /* PermissionsComponent.swift in Sources */,
217
- EBE000000000000000000058 /* BiometricsComponent.swift in Sources */,
218
- EBE000000000000000000060 /* StorageComponent.swift in Sources */,
219
- EBE000000000000000000068 /* MenuComponent.swift in Sources */,
220
191
  EBE000000000000000000063 /* EverywhereHost.swift in Sources */,
221
192
  EBE000000000000000000070 /* AuthFlow.swift in Sources */,
222
193
  );
@@ -22,13 +22,21 @@ this template, it overwrites **exactly** these files and nothing else:
22
22
  6. *(optionally)* `App/Info.plist`'s `CFBundleURLTypes` — the sign-in callback
23
23
  scheme, written **only** when `auth:` is set in `everywhere.yml` (alongside
24
24
  the permission usage strings, which are stamped the same way)
25
- 7. *(optionally)* `App/App.entitlements` — the Associated Domains entitlement,
26
- written **only** when `deep_linking:` is set in `everywhere.yml`. The matching
27
- `CODE_SIGN_ENTITLEMENTS` is added to the xcconfig in the same case, so the
28
- default build stays entitlement-free and the frozen template still compiles
29
- unchanged. The gem serves the `/.well-known/apple-app-site-association` file
30
- these domains point at; `SceneDelegate` routes an incoming universal link to
31
- its in-app path.
25
+ 7. *(optionally)* `App/App.entitlements` — merged from every entitlement
26
+ capability the app has configured, written **only** when at least one grants
27
+ a key. The matching `CODE_SIGN_ENTITLEMENTS` is added to the xcconfig in
28
+ exactly that case, so the default build stays entitlement-free and the frozen
29
+ template still compiles unchanged.
30
+
31
+ Capabilities are an ordered registry in the builder
32
+ (`ENTITLEMENT_CAPABILITIES` in `lib/everywhere/builders/ios.rb`); each
33
+ contributes keys or nothing. Today the only member is Associated Domains,
34
+ written when `deep_linking:` is set in `everywhere.yml` — it grants
35
+ `applinks:` and `webcredentials:` per host. The gem serves the
36
+ `/.well-known/apple-app-site-association` file these domains point at;
37
+ `SceneDelegate` routes an incoming universal link to its in-app path. Note
38
+ that `webcredentials:` is what makes passkeys / WebAuthn work in the web
39
+ view, so deep linking is a de facto prerequisite for those.
32
40
 
33
41
  Everything the CLI needs to vary per app flows through those files. The product
34
42
  is always `App.app` (`PRODUCT_NAME = App` is hardcoded); identity comes from the
@@ -40,6 +48,16 @@ xcconfig (`PRODUCT_BUNDLE_IDENTIFIER`, `MARKETING_VERSION`,
40
48
  bumps) mean deliberately editing `project.pbxproj` here in the template and
41
49
  recommitting — never in a stamped copy.
42
50
 
51
+ **Exception — `App/Bridge/` and `App/Extensions/`.** Both are
52
+ `PBXFileSystemSynchronizedRootGroup`s, so Xcode picks up whatever is on disk:
53
+ dropping a `.swift` file into either directory *is* the integration, and needs
54
+ no pbxproj edit. That is the whole point of the synchronized group — a new
55
+ bridge component costs one new file plus its registration in
56
+ `AppDelegate.configureHotwire`, not four hand-written pbxproj entries with
57
+ hand-assigned UUIDs. Files added to a synchronized group must not also get
58
+ explicit `PBXBuildFile` / `PBXFileReference` / `PBXSourcesBuildPhase` entries,
59
+ or they compile twice and the build fails on duplicate symbols.
60
+
43
61
  ## SPM pin policy
44
62
 
45
63
  The project depends on one remote package: the fork
@@ -67,6 +85,14 @@ xcodebuild build -project App.xcodeproj -scheme App -destination 'generic/platfo
67
85
 
68
86
  The shared `App` scheme is required for headless `xcodebuild` — do not delete it.
69
87
 
88
+ **Running `xcodebuild` here rewrites `Package.resolved` as a side effect.** Xcode
89
+ adds an `originHash` key and re-adds the source `branch` key to the pin, leaving
90
+ the `revision` SHA correct but the file no longer matching the pin policy above.
91
+ That trips `IosTemplatePinTest#test_package_resolved_state_has_no_branch`. It is
92
+ a byproduct of invoking `xcodebuild`, not of any edit you made — revert it
93
+ (`git checkout -- App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved`)
94
+ before committing.
95
+
70
96
  ## Dev loop
71
97
 
72
98
  The shell is remote-mode only (`remote_url` in everywhere.json). For local
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby_everywhere
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.10.1
4
+ version: 0.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrea Fomera
@@ -187,6 +187,7 @@ files:
187
187
  - lib/everywhere/raw_tty.rb
188
188
  - lib/everywhere/receipt.rb
189
189
  - lib/everywhere/relay.rb
190
+ - lib/everywhere/request_context.rb
190
191
  - lib/everywhere/s3.rb
191
192
  - lib/everywhere/shell_pages.rb
192
193
  - lib/everywhere/shellout.rb