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 +4 -4
- data/README.md +22 -2
- data/bridge/everywhere/bridge.js +22 -3
- data/bridge/package.json +3 -0
- data/lib/everywhere/android_sdk.rb +51 -10
- data/lib/everywhere/builders/android.rb +11 -2
- data/lib/everywhere/builders/ios.rb +88 -19
- data/lib/everywhere/commands/doctor.rb +1 -1
- data/lib/everywhere/commands/install.rb +9 -0
- data/lib/everywhere/config/app.rb +44 -0
- data/lib/everywhere/config/data.rb +8 -0
- data/lib/everywhere/config/mobile.rb +18 -4
- data/lib/everywhere/config.rb +1 -0
- data/lib/everywhere/mobile_config_endpoint.rb +1 -1
- data/lib/everywhere/mobile_configs_controller.rb +1 -1
- data/lib/everywhere/native_helper.rb +5 -0
- data/lib/everywhere/request_context.rb +97 -0
- data/lib/everywhere/tab_filter.rb +74 -15
- data/lib/everywhere/version.rb +1 -1
- data/support/agents/frameworks/rails-views.md +5 -2
- data/support/mobile/android/README.md +7 -4
- data/support/mobile/android/app/build.gradle.kts +16 -2
- data/support/mobile/android/app/everywhere.properties +3 -0
- data/support/mobile/ios/App.xcodeproj/project.pbxproj +2 -31
- data/support/mobile/ios/README.md +33 -7
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a690caacf23100232c7d96fb2a5575d2408a475219ecd1290d6b03a6e69917e4
|
|
4
|
+
data.tar.gz: 98285827b8cac459773c44118c15f4447bbf5b3bbc182dbd49593c32e165a30c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
235
|
-
|
|
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
|
|
data/bridge/everywhere/bridge.js
CHANGED
|
@@ -907,7 +907,9 @@ export const Everywhere = {
|
|
|
907
907
|
message: options.message,
|
|
908
908
|
items,
|
|
909
909
|
source: options.source
|
|
910
|
-
|
|
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:
|
|
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:
|
|
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
|
-
#
|
|
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 =
|
|
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
|
-
#
|
|
277
|
-
#
|
|
278
|
-
def
|
|
279
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
#
|
|
176
|
-
#
|
|
177
|
-
# entitlement-free, so the frozen template still compiles
|
|
178
|
-
# matching CODE_SIGN_ENTITLEMENTS is set in the xcconfig
|
|
179
|
-
#
|
|
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
|
-
|
|
183
|
-
return
|
|
193
|
+
keys = entitlement_keys
|
|
194
|
+
return if keys.empty?
|
|
184
195
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
\
|
|
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
|
|
420
|
-
# empty CODE_SIGN_ENTITLEMENTS would break the signing step.
|
|
421
|
-
|
|
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
|
|
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))
|
|
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
|
-
|
|
36
|
-
|
|
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
|
data/lib/everywhere/config.rb
CHANGED
|
@@ -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
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
# the
|
|
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
|
-
#
|
|
11
|
-
#
|
|
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
|
-
#
|
|
15
|
-
#
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
|
34
|
-
attr_writer :tabs_filter
|
|
92
|
+
# Test/reset hooks.
|
|
93
|
+
attr_writer :tabs_filter, :current_user_resolver
|
|
35
94
|
end
|
|
36
95
|
end
|
data/lib/everywhere/version.rb
CHANGED
|
@@ -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.
|
|
95
|
-
|
|
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
|
|
19
|
-
without ever touching a build script. `app/build.gradle.kts` reads
|
|
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`
|
|
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
|
-
|
|
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
|
-
|
|
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` —
|
|
26
|
-
written **only** when
|
|
27
|
-
`CODE_SIGN_ENTITLEMENTS` is added to the xcconfig in
|
|
28
|
-
default build stays entitlement-free and the frozen
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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.
|
|
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
|