ruby_everywhere 0.1.15 → 0.2.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/bridge/README.md +57 -2
- data/bridge/everywhere/bridge.js +803 -9
- data/bridge/package.json +1 -1
- data/lib/everywhere/builders/ios.rb +364 -0
- data/lib/everywhere/cli.rb +2 -0
- data/lib/everywhere/commands/build.rb +17 -1
- data/lib/everywhere/commands/clean.rb +19 -10
- data/lib/everywhere/commands/dev.rb +182 -19
- data/lib/everywhere/commands/doctor.rb +45 -3
- data/lib/everywhere/commands/icon.rb +14 -0
- data/lib/everywhere/commands/install.rb +37 -6
- data/lib/everywhere/commands/logs.rb +56 -0
- data/lib/everywhere/commands/platform/build.rb +3 -3
- data/lib/everywhere/commands/platform/runner.rb +1 -1
- data/lib/everywhere/commands/release.rb +1 -1
- data/lib/everywhere/commands/shell_dir.rb +11 -4
- data/lib/everywhere/config.rb +366 -1
- data/lib/everywhere/engine.rb +33 -1
- data/lib/everywhere/icon.rb +50 -0
- data/lib/everywhere/log_filter.rb +28 -2
- data/lib/everywhere/mobile_config_endpoint.rb +75 -0
- data/lib/everywhere/mobile_configs_controller.rb +46 -0
- data/lib/everywhere/native_helper.rb +156 -0
- data/lib/everywhere/paths.rb +41 -0
- data/lib/everywhere/raster.rb +17 -0
- data/lib/everywhere/shellout.rb +3 -1
- data/lib/everywhere/simulator.rb +74 -0
- data/lib/everywhere/ui.rb +18 -0
- data/lib/everywhere/version.rb +1 -1
- data/support/mobile/ios/App/App.xcconfig +6 -0
- data/support/mobile/ios/App/AppDelegate.swift +163 -0
- data/support/mobile/ios/App/Assets.xcassets/AccentColor.colorset/Contents.json +20 -0
- data/support/mobile/ios/App/Assets.xcassets/AppIcon.appiconset/AppIcon.png +0 -0
- data/support/mobile/ios/App/Assets.xcassets/AppIcon.appiconset/Contents.json +14 -0
- data/support/mobile/ios/App/Assets.xcassets/Contents.json +6 -0
- data/support/mobile/ios/App/Assets.xcassets/LaunchBackground.colorset/Contents.json +38 -0
- data/support/mobile/ios/App/Base.lproj/LaunchScreen.storyboard +32 -0
- data/support/mobile/ios/App/Bridge/BiometricsComponent.swift +276 -0
- data/support/mobile/ios/App/Bridge/HapticsComponent.swift +47 -0
- data/support/mobile/ios/App/Bridge/NotificationComponent.swift +56 -0
- data/support/mobile/ios/App/Bridge/PermissionsComponent.swift +142 -0
- data/support/mobile/ios/App/Bridge/StorageComponent.swift +63 -0
- data/support/mobile/ios/App/ErrorViewController.swift +64 -0
- data/support/mobile/ios/App/EverywhereConfig.swift +268 -0
- data/support/mobile/ios/App/EverywhereHost.swift +34 -0
- data/support/mobile/ios/App/Extensions/EverywhereExtensions.swift +32 -0
- data/support/mobile/ios/App/Info.plist +28 -0
- data/support/mobile/ios/App/Resources/everywhere.json +8 -0
- data/support/mobile/ios/App/Resources/path-configuration.json +19 -0
- data/support/mobile/ios/App/SceneDelegate.swift +443 -0
- data/support/mobile/ios/App.xcodeproj/project.pbxproj +454 -0
- data/support/mobile/ios/App.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
- data/support/mobile/ios/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +14 -0
- data/support/mobile/ios/App.xcodeproj/xcshareddata/xcschemes/App.xcscheme +77 -0
- data/support/mobile/ios/NativeExtensions/Package.swift +26 -0
- data/support/mobile/ios/NativeExtensions/Sources/NativeExtensions/Exports.swift +5 -0
- data/support/mobile/ios/README.md +66 -0
- metadata +35 -1
data/lib/everywhere/config.rb
CHANGED
|
@@ -3,6 +3,62 @@
|
|
|
3
3
|
require "yaml"
|
|
4
4
|
|
|
5
5
|
module Everywhere
|
|
6
|
+
class << self
|
|
7
|
+
# Register a per-request filter for the mobile tab bar. The block receives
|
|
8
|
+
# the resolved tab list (`[{ "title" =>, "path" =>, "icon" => }, …]`) and
|
|
9
|
+
# the current request, and returns the subset to show — return `[]` to hide
|
|
10
|
+
# the tab bar entirely (the shell falls back to single-screen navigation).
|
|
11
|
+
#
|
|
12
|
+
# It runs inside the mobile config endpoint, which shares the app's session
|
|
13
|
+
# and cookies, so it can branch on auth or any request state:
|
|
14
|
+
#
|
|
15
|
+
# # config/initializers/everywhere.rb
|
|
16
|
+
# Everywhere.filter_tabs do |tabs, request|
|
|
17
|
+
# request.session[:user_id] ? tabs : []
|
|
18
|
+
# end
|
|
19
|
+
#
|
|
20
|
+
# Live like the rest of the config: the shell re-reads it on launch and on
|
|
21
|
+
# every foreground, so a sign-in shows the tabs on next foreground — no
|
|
22
|
+
# rebuild, no app-store release.
|
|
23
|
+
def filter_tabs(&block)
|
|
24
|
+
@tabs_filter = block
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Apply the registered filter (identity when none is set). Always returns
|
|
28
|
+
# an Array so a stray nil/scalar from a block can't break serialization.
|
|
29
|
+
def resolve_tabs(tabs, request)
|
|
30
|
+
return tabs unless @tabs_filter
|
|
31
|
+
|
|
32
|
+
Array(@tabs_filter.call(tabs, request))
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The tiny page served at /everywhere/reset. Auth flows redirect the native
|
|
36
|
+
# app here so it resets cleanly (fresh web views, re-fetched tabs) before
|
|
37
|
+
# continuing — the standard Hotwire Native "reset the app" pattern. It talks
|
|
38
|
+
# to the shell's control channel directly (no bridge/importmap dependency);
|
|
39
|
+
# in a plain browser it just forwards to the target. `to` is constrained to
|
|
40
|
+
# a same-origin path.
|
|
41
|
+
def mobile_reset_html(to)
|
|
42
|
+
require "json"
|
|
43
|
+
target = to.to_s
|
|
44
|
+
target = "/" unless target.start_with?("/") && !target.start_with?("//")
|
|
45
|
+
encoded = target.to_json
|
|
46
|
+
|
|
47
|
+
<<~HTML
|
|
48
|
+
<!DOCTYPE html><html><head><meta charset="utf-8"><title>One moment…</title>
|
|
49
|
+
<meta name="viewport" content="width=device-width,initial-scale=1"></head>
|
|
50
|
+
<body><script>
|
|
51
|
+
(function(){var to=#{encoded};
|
|
52
|
+
var ch=window.webkit&&window.webkit.messageHandlers&&window.webkit.messageHandlers.everywhereControl;
|
|
53
|
+
if(ch){ch.postMessage({action:"reset",to:to});}else{window.location.replace(to);}})();
|
|
54
|
+
</script></body></html>
|
|
55
|
+
HTML
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Test/reset hook.
|
|
59
|
+
attr_writer :tabs_filter
|
|
60
|
+
end
|
|
61
|
+
|
|
6
62
|
# Loads config/everywhere.yml:
|
|
7
63
|
#
|
|
8
64
|
# app:
|
|
@@ -109,6 +165,16 @@ module Everywhere
|
|
|
109
165
|
@data.dig("remote", "url")&.chomp("/")
|
|
110
166
|
end
|
|
111
167
|
|
|
168
|
+
# Multi-instance apps (`remote.instances: true`): the mobile shell boots
|
|
169
|
+
# into remote.url — a hosted instance-picker page — and lets that page
|
|
170
|
+
# re-root the app onto a chosen instance via Everywhere.instance.set,
|
|
171
|
+
# persisted across launches until Everywhere.instance.clear. Opt-in
|
|
172
|
+
# because it lets page JS repoint the whole app: apps that aren't
|
|
173
|
+
# multi-instance shouldn't carry that surface.
|
|
174
|
+
def remote_instances?
|
|
175
|
+
@data.dig("remote", "instances") == true
|
|
176
|
+
end
|
|
177
|
+
|
|
112
178
|
# The `updates:` section — self-hosted auto-updates. Two halves:
|
|
113
179
|
# a client half (url/channel/public_key/auto/interval) that ships to the
|
|
114
180
|
# shell, and a publish-side `s3:` half that NEVER leaves the dev machine.
|
|
@@ -153,6 +219,301 @@ module Everywhere
|
|
|
153
219
|
normalize_color(appearance["background_color"])
|
|
154
220
|
end
|
|
155
221
|
|
|
222
|
+
# Mobile tab bar, platform-neutral. Icons are per-platform (ios = SF
|
|
223
|
+
# Symbol, android = Material icon later), with a shared `icon:` fallback:
|
|
224
|
+
#
|
|
225
|
+
# tabs:
|
|
226
|
+
# - name: Builds
|
|
227
|
+
# path: /builds
|
|
228
|
+
# icons:
|
|
229
|
+
# ios: hammer
|
|
230
|
+
# android: build
|
|
231
|
+
#
|
|
232
|
+
# Tabs ship two ways from the same source: baked into the app's bundled
|
|
233
|
+
# path-configuration.json at build time (offline/first-launch), and served
|
|
234
|
+
# live from /everywhere/<platform>_v1.json (MobileConfigEndpoint) so tab
|
|
235
|
+
# changes deploy with the web app — no app-store release.
|
|
236
|
+
def tabs
|
|
237
|
+
entries = @data["tabs"]
|
|
238
|
+
return [] unless entries.is_a?(Array)
|
|
239
|
+
|
|
240
|
+
entries.filter_map do |tab|
|
|
241
|
+
next unless tab.is_a?(Hash) && tab["name"] && tab["path"]
|
|
242
|
+
|
|
243
|
+
path = tab["path"].start_with?("/") ? tab["path"] : "/#{tab["path"]}"
|
|
244
|
+
{ "name" => tab["name"], "path" => path,
|
|
245
|
+
"icon" => tab["icon"], "icons" => (tab["icons"] if tab["icons"].is_a?(Hash)) }.compact
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Tabs with the icon resolved for one platform: icons.<os>, then the
|
|
250
|
+
# shared icon, then a safe default.
|
|
251
|
+
def tabs_for(os)
|
|
252
|
+
tabs.map do |tab|
|
|
253
|
+
{ "title" => tab["name"], "path" => tab["path"],
|
|
254
|
+
"icon" => tab.dig("icons", os.to_s) || tab["icon"] || DEFAULT_TAB_ICONS[os.to_s] }.compact
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
DEFAULT_TAB_ICONS = { "ios" => "circle", "android" => "circle" }.freeze
|
|
259
|
+
|
|
260
|
+
# The Hotwire Native path-configuration rules every RubyEverywhere mobile
|
|
261
|
+
# shell starts from. Single source of truth: the builder bakes this into
|
|
262
|
+
# the app bundle and MobileConfigEndpoint serves it live.
|
|
263
|
+
MOBILE_PATH_RULES = [
|
|
264
|
+
{ "patterns" => [".*"],
|
|
265
|
+
"properties" => { "context" => "default", "pull_to_refresh_enabled" => true } },
|
|
266
|
+
{ "patterns" => ["/new$", "/edit$"],
|
|
267
|
+
"properties" => { "context" => "modal", "pull_to_refresh_enabled" => false } }
|
|
268
|
+
].freeze
|
|
269
|
+
|
|
270
|
+
# Native permissions the app declares (top-level `permissions:`). Only
|
|
271
|
+
# declared permissions can be requested at runtime — the shell answers
|
|
272
|
+
# `undeclared` for everything else, so an app that doesn't need a
|
|
273
|
+
# permission can never prompt for it. For camera and location the value is
|
|
274
|
+
# the user-facing usage string iOS shows in the permission prompt (the
|
|
275
|
+
# "why"); it's mandatory there because requesting without one crashes the
|
|
276
|
+
# app. Notifications need no string — declare with `true`.
|
|
277
|
+
#
|
|
278
|
+
# permissions:
|
|
279
|
+
# notifications: true
|
|
280
|
+
# camera: "Scan QR codes to pair devices."
|
|
281
|
+
# location:
|
|
282
|
+
# ios: "Find build agents near you."
|
|
283
|
+
# biometrics: "Unlock your account with Face ID."
|
|
284
|
+
#
|
|
285
|
+
# Returns { name => { "ios" => usage-or-nil, ... } }.
|
|
286
|
+
MOBILE_PERMISSIONS = %w[notifications camera location biometrics].freeze
|
|
287
|
+
|
|
288
|
+
# Info.plist usage-description keys per permission. Presence here makes
|
|
289
|
+
# the usage string mandatory on iOS.
|
|
290
|
+
IOS_USAGE_KEYS = {
|
|
291
|
+
"camera" => "NSCameraUsageDescription",
|
|
292
|
+
"location" => "NSLocationWhenInUseUsageDescription",
|
|
293
|
+
"biometrics" => "NSFaceIDUsageDescription"
|
|
294
|
+
}.freeze
|
|
295
|
+
|
|
296
|
+
def permissions
|
|
297
|
+
entries = @data["permissions"]
|
|
298
|
+
return {} unless entries.is_a?(Hash)
|
|
299
|
+
|
|
300
|
+
entries.each_with_object({}) do |(name, value), acc|
|
|
301
|
+
next if value.nil? || value == false
|
|
302
|
+
|
|
303
|
+
acc[name.to_s] =
|
|
304
|
+
case value
|
|
305
|
+
when String then { "ios" => value }
|
|
306
|
+
when Hash then value.transform_keys(&:to_s).transform_values(&:to_s)
|
|
307
|
+
else {}
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# Problems with the permissions declaration for one platform, as
|
|
313
|
+
# human-readable strings — unknown names, and camera/location missing the
|
|
314
|
+
# mandatory usage string. Empty means buildable.
|
|
315
|
+
def permission_errors(os)
|
|
316
|
+
permissions.flat_map do |name, usage|
|
|
317
|
+
unless MOBILE_PERMISSIONS.include?(name)
|
|
318
|
+
next ["unknown permission #{name.inspect} — supported: #{MOBILE_PERMISSIONS.join(", ")}"]
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
if os == "ios" && IOS_USAGE_KEYS.key?(name) && usage["ios"].to_s.strip.empty?
|
|
322
|
+
next ["#{name} needs a usage string — the sentence iOS shows when asking. " \
|
|
323
|
+
"In config/everywhere.yml:\n permissions:\n #{name}: \"Why the app needs #{name}.\""]
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
[]
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
# App-declared Hotwire Native path-configuration rules (everywhere.yml
|
|
331
|
+
# top-level `rules:`), appended after MOBILE_PATH_RULES — later rules win
|
|
332
|
+
# per property, so apps can make routes modal, disable pull-to-refresh,
|
|
333
|
+
# or set any other Hotwire path property without touching native code:
|
|
334
|
+
#
|
|
335
|
+
# rules:
|
|
336
|
+
# - patterns: ["/preferences$"]
|
|
337
|
+
# properties:
|
|
338
|
+
# context: default # not a modal, unlike other /edit routes
|
|
339
|
+
# - patterns: ["/live/"]
|
|
340
|
+
# properties:
|
|
341
|
+
# pull_to_refresh_enabled: false
|
|
342
|
+
def mobile_rules
|
|
343
|
+
entries = @data["rules"]
|
|
344
|
+
return [] unless entries.is_a?(Array)
|
|
345
|
+
|
|
346
|
+
entries.filter_map do |rule|
|
|
347
|
+
next unless rule.is_a?(Hash) && rule["patterns"].is_a?(Array) && rule["properties"].is_a?(Hash)
|
|
348
|
+
|
|
349
|
+
{ "patterns" => rule["patterns"].map(&:to_s), "properties" => rule["properties"] }
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
# The full path-configuration document for one mobile platform — rules plus
|
|
354
|
+
# our settings (tabs live in settings, per Hotwire Native convention).
|
|
355
|
+
# Pass `tabs:` to override the resolved list (the mobile config endpoint
|
|
356
|
+
# passes a per-request-filtered list; build-time stamping omits it and
|
|
357
|
+
# bakes them all).
|
|
358
|
+
def path_configuration_hash(os, tabs: nil)
|
|
359
|
+
settings = {}
|
|
360
|
+
platform_tabs = tabs || tabs_for(os)
|
|
361
|
+
settings["tabs"] = platform_tabs unless platform_tabs.empty?
|
|
362
|
+
{ "settings" => settings, "rules" => MOBILE_PATH_RULES + mobile_rules }
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def path_configuration_json(os, tabs: nil)
|
|
366
|
+
require "json"
|
|
367
|
+
JSON.generate(path_configuration_hash(os, tabs: tabs))
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
# Native code extensions ("supernative"): Swift the app repo carries in
|
|
371
|
+
# native/ios/ that `every build` compiles into the shell, plus this
|
|
372
|
+
# declaration of what to hook up. Build-time only — stores forbid
|
|
373
|
+
# downloading native code, so extensions ship with the app binary.
|
|
374
|
+
#
|
|
375
|
+
# native:
|
|
376
|
+
# ios:
|
|
377
|
+
# components: [ChartComponent] # BridgeComponent subclasses to register
|
|
378
|
+
# screens: # path rules with view_controller: <id>
|
|
379
|
+
# map: MapScreen # SwiftUI View or UIViewController, init(url:)
|
|
380
|
+
# splash: LaunchSplash # SwiftUI View or UIViewController, init()
|
|
381
|
+
# splash_min_seconds: 1.0 # how long the custom splash stays up at minimum
|
|
382
|
+
# lazy_load_tabs: true # defer each tab's first visit until it's selected
|
|
383
|
+
def native_ios
|
|
384
|
+
section = @data.dig("native", "ios")
|
|
385
|
+
section.is_a?(Hash) ? section : {}
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
def native_ios_components = Array(native_ios["components"]).map(&:to_s)
|
|
389
|
+
|
|
390
|
+
def native_ios_screens
|
|
391
|
+
entries = native_ios["screens"]
|
|
392
|
+
return {} unless entries.is_a?(Hash)
|
|
393
|
+
|
|
394
|
+
entries.to_h { |id, type| [id.to_s, type.to_s] }
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
def native_ios_splash = native_ios["splash"]&.to_s
|
|
398
|
+
|
|
399
|
+
# Minimum seconds a custom splash stays on screen (default 1.0 shell-side;
|
|
400
|
+
# a fast server would otherwise dismiss a branded splash in a ~50ms flash).
|
|
401
|
+
# Clamped to the shell's 8s give-up timeout.
|
|
402
|
+
def native_ios_splash_min_seconds
|
|
403
|
+
value = native_ios["splash_min_seconds"]
|
|
404
|
+
value.to_f.clamp(0.0, 8.0) if value.is_a?(Numeric)
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
# Whether the native tab bar defers each non-selected tab's initial visit
|
|
408
|
+
# until the tab is first selected (Hotwire Native `lazyLoadTabs`). Tri-state
|
|
409
|
+
# so apps can opt in or opt out explicitly: `true` lazy-loads, `false` loads
|
|
410
|
+
# every tab up front, and an absent key leaves the shell on its default
|
|
411
|
+
# (eager).
|
|
412
|
+
def native_ios_lazy_load_tabs
|
|
413
|
+
value = native_ios["lazy_load_tabs"]
|
|
414
|
+
value if value == true || value == false
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
# Third-party Swift packages this app pins for its native/ios code. Each
|
|
418
|
+
# entry is one SPM dependency; `every build` writes them into the shell's
|
|
419
|
+
# local NativeExtensions package and re-exports every product, so native/ios
|
|
420
|
+
# code reaches them with a single `import NativeExtensions`.
|
|
421
|
+
#
|
|
422
|
+
# packages:
|
|
423
|
+
# - url: https://github.com/simibac/ConfettiSwiftUI
|
|
424
|
+
# from: "1.1.0" # or exact: / branch: / revision:
|
|
425
|
+
# products: [ConfettiSwiftUI] # optional; defaults to the repo name
|
|
426
|
+
#
|
|
427
|
+
# Returns normalized entries: { "url", "requirement" => { "kind", "value" },
|
|
428
|
+
# "products" => [...] }. Assumes the config validated clean (see
|
|
429
|
+
# native_ios_package_errors) — the requirement is always present here.
|
|
430
|
+
PACKAGE_REQUIREMENT_KEYS = %w[from exact branch revision].freeze
|
|
431
|
+
|
|
432
|
+
def native_ios_packages
|
|
433
|
+
Array(native_ios["packages"]).filter_map do |entry|
|
|
434
|
+
next unless entry.is_a?(Hash)
|
|
435
|
+
|
|
436
|
+
url = entry["url"].to_s.strip
|
|
437
|
+
kind = PACKAGE_REQUIREMENT_KEYS.find { |k| entry.key?(k) && !entry[k].to_s.strip.empty? }
|
|
438
|
+
products = Array(entry["products"]).map { |p| p.to_s.strip }.reject(&:empty?)
|
|
439
|
+
products = [package_identity(url)] if products.empty?
|
|
440
|
+
|
|
441
|
+
{ "url" => url,
|
|
442
|
+
"requirement" => (kind && { "kind" => kind, "value" => entry[kind].to_s.strip }),
|
|
443
|
+
"products" => products }
|
|
444
|
+
end
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
def native_ios_packages? = !native_ios_packages.empty?
|
|
448
|
+
|
|
449
|
+
# SPM's package identity: the URL's last path segment without a .git suffix
|
|
450
|
+
# (github.com/simibac/ConfettiSwiftUI → "ConfettiSwiftUI"). Used as the
|
|
451
|
+
# default product name and as the `package:` key in .product(name:package:).
|
|
452
|
+
def package_identity(url) = File.basename(url.to_s.sub(%r{/+\z}, "")).sub(/\.git\z/, "")
|
|
453
|
+
|
|
454
|
+
def native_ios? = !(native_ios_components.empty? && native_ios_screens.empty? && native_ios_splash.nil?)
|
|
455
|
+
|
|
456
|
+
# Everything declared here is interpolated into generated Swift, so names
|
|
457
|
+
# are validated hard: type names must be plain Swift identifiers, screen
|
|
458
|
+
# identifiers must be safe inside a Swift string literal.
|
|
459
|
+
SWIFT_TYPE_NAME = /\A[A-Za-z_][A-Za-z0-9_]*\z/
|
|
460
|
+
SCREEN_IDENTIFIER = /\A[A-Za-z0-9_-]+\z/
|
|
461
|
+
|
|
462
|
+
def native_ios_errors
|
|
463
|
+
types = native_ios_components + native_ios_screens.values + [native_ios_splash].compact
|
|
464
|
+
errors = types.reject { |t| t.match?(SWIFT_TYPE_NAME) }.map do |t|
|
|
465
|
+
"native.ios: #{t.inspect} is not a Swift type name (letters, digits, _)"
|
|
466
|
+
end
|
|
467
|
+
errors += native_ios_screens.keys.reject { |id| id.match?(SCREEN_IDENTIFIER) }.map do |id|
|
|
468
|
+
"native.ios.screens: identifier #{id.inspect} must match #{SCREEN_IDENTIFIER.inspect}"
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
raw = native_ios["splash_min_seconds"]
|
|
472
|
+
errors << "native.ios.splash_min_seconds must be a number (seconds)" if raw && !raw.is_a?(Numeric)
|
|
473
|
+
errors + native_ios_package_errors
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
# A URL SPM accepts (https for the common case, git@ for SSH remotes) and a
|
|
477
|
+
# loose semver for from:/exact: — enough to catch typos without reimplementing
|
|
478
|
+
# SPM's resolver, which reports anything subtler at build time.
|
|
479
|
+
PACKAGE_URL = %r{\A(https?://|git@).+}
|
|
480
|
+
PACKAGE_VERSION = /\A\d+(\.\d+){0,2}(-[0-9A-Za-z.-]+)?\z/
|
|
481
|
+
|
|
482
|
+
def native_ios_package_errors
|
|
483
|
+
raw = native_ios["packages"]
|
|
484
|
+
return [] if raw.nil?
|
|
485
|
+
return ["native.ios.packages must be a list"] unless raw.is_a?(Array)
|
|
486
|
+
|
|
487
|
+
raw.each_with_index.flat_map do |entry, i|
|
|
488
|
+
at = "native.ios.packages[#{i}]"
|
|
489
|
+
next ["#{at} must be a mapping with a url:"] unless entry.is_a?(Hash)
|
|
490
|
+
|
|
491
|
+
errors = []
|
|
492
|
+
url = entry["url"].to_s.strip
|
|
493
|
+
if url.empty?
|
|
494
|
+
errors << "#{at}.url is required"
|
|
495
|
+
elsif !url.match?(PACKAGE_URL)
|
|
496
|
+
errors << "#{at}.url #{url.inspect} must be an https:// or git@ URL"
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
present = PACKAGE_REQUIREMENT_KEYS.select { |k| entry.key?(k) && !entry[k].to_s.strip.empty? }
|
|
500
|
+
if present.empty?
|
|
501
|
+
errors << "#{at} needs one version requirement (#{PACKAGE_REQUIREMENT_KEYS.join(" / ")})"
|
|
502
|
+
elsif present.length > 1
|
|
503
|
+
errors << "#{at} sets conflicting requirements (#{present.join(", ")}) — pick one"
|
|
504
|
+
elsif %w[from exact].include?(present.first) && !entry[present.first].to_s.strip.match?(PACKAGE_VERSION)
|
|
505
|
+
errors << "#{at}.#{present.first} #{entry[present.first].to_s.inspect} must be a version like \"1.2.0\""
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
Array(entry["products"]).each do |product|
|
|
509
|
+
next if product.to_s.match?(SWIFT_TYPE_NAME)
|
|
510
|
+
|
|
511
|
+
errors << "#{at}.products: #{product.to_s.inspect} is not a module name (letters, digits, _)"
|
|
512
|
+
end
|
|
513
|
+
errors
|
|
514
|
+
end
|
|
515
|
+
end
|
|
516
|
+
|
|
156
517
|
# Custom native menu items (macOS app menu). Each entry: label + path
|
|
157
518
|
# (+ optional accelerator), or "separator". Clicks arrive in the page as
|
|
158
519
|
# "everywhere:menu" events and navigate via Turbo.
|
|
@@ -204,12 +565,16 @@ module Everywhere
|
|
|
204
565
|
"version" => version(target: target),
|
|
205
566
|
"mode" => mode,
|
|
206
567
|
"remote_url" => remote_url,
|
|
568
|
+
"remote_instances" => (true if remote_instances?),
|
|
207
569
|
"entry_path" => entry_path,
|
|
208
570
|
"tint_color" => tint_color,
|
|
209
571
|
"background_color" => background_color,
|
|
210
572
|
"menu" => (menu unless menu.empty?),
|
|
211
573
|
"tray" => (tray unless tray.empty?),
|
|
212
|
-
"
|
|
574
|
+
"lazy_load_tabs" => native_ios_lazy_load_tabs,
|
|
575
|
+
"splash_min_seconds" => native_ios_splash_min_seconds,
|
|
576
|
+
"updates" => updates_shell_hash,
|
|
577
|
+
"permissions" => (permissions.keys unless permissions.empty?)
|
|
213
578
|
}.compact
|
|
214
579
|
end
|
|
215
580
|
|
data/lib/everywhere/engine.rb
CHANGED
|
@@ -15,7 +15,19 @@ module Everywhere
|
|
|
15
15
|
initializer "everywhere.assets" do |app|
|
|
16
16
|
if app.config.respond_to?(:assets)
|
|
17
17
|
app.config.assets.paths << Engine.root.join("bridge").to_s
|
|
18
|
-
|
|
18
|
+
if app.config.assets.respond_to?(:precompile)
|
|
19
|
+
app.config.assets.precompile += %w[everywhere/bridge.js everywhere/native.css]
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Make native_app? / native_platform / native_version available in every
|
|
25
|
+
# view (and controllers, via helper_method-style access through helpers).
|
|
26
|
+
initializer "everywhere.helpers" do
|
|
27
|
+
require_relative "native_helper"
|
|
28
|
+
ActiveSupport.on_load(:action_controller) do
|
|
29
|
+
include Everywhere::NativeHelper # controllers: native_app?, everywhere_auth_redirect
|
|
30
|
+
helper Everywhere::NativeHelper # views: native_app?, native_platform, native_version
|
|
19
31
|
end
|
|
20
32
|
end
|
|
21
33
|
|
|
@@ -26,5 +38,25 @@ module Everywhere
|
|
|
26
38
|
app.config.importmap.paths << Engine.root.join("config/importmap.rb")
|
|
27
39
|
end
|
|
28
40
|
end
|
|
41
|
+
|
|
42
|
+
# /everywhere/ios_v1.json — the mobile shells' remote path configuration
|
|
43
|
+
# (rules + settings.tabs), generated from config/everywhere.yml at request
|
|
44
|
+
# time so tab changes ship with a normal deploy. A real controller (not
|
|
45
|
+
# middleware — that's the Sinatra/Hanami strategy, MobileConfigEndpoint):
|
|
46
|
+
# it logs, instruments, and lists in `rails routes`. Appended routes have
|
|
47
|
+
# the lowest priority, so an app can draw its own to override.
|
|
48
|
+
initializer "everywhere.mobile_config" do |app|
|
|
49
|
+
app.routes.append do
|
|
50
|
+
# Required here — routes are drawn after the framework is fully
|
|
51
|
+
# booted, so the controller class loads with everything available.
|
|
52
|
+
require_relative "mobile_configs_controller"
|
|
53
|
+
get "/everywhere/reset", to: Everywhere::MobileConfigsController.action(:reset),
|
|
54
|
+
as: :everywhere_reset
|
|
55
|
+
get "/everywhere/:platform_v1", to: Everywhere::MobileConfigsController.action(:show),
|
|
56
|
+
constraints: { platform_v1: /(?:ios|android)_v1/ },
|
|
57
|
+
defaults: { format: "json" },
|
|
58
|
+
as: :everywhere_mobile_config
|
|
59
|
+
end
|
|
60
|
+
end
|
|
29
61
|
end
|
|
30
62
|
end
|
data/lib/everywhere/icon.rb
CHANGED
|
@@ -37,6 +37,25 @@ module Everywhere
|
|
|
37
37
|
ICO_SIZES = [16, 32, 48, 64, 128, 256].freeze
|
|
38
38
|
LINUX_SIZES = [16, 32, 48, 64, 128, 256, 512].freeze
|
|
39
39
|
|
|
40
|
+
# Modern (Xcode 14+) single-size asset catalog entry: one 1024px universal
|
|
41
|
+
# image, iOS derives every size itself.
|
|
42
|
+
IOS_APPICON_CONTENTS = <<~JSON
|
|
43
|
+
{
|
|
44
|
+
"images" : [
|
|
45
|
+
{
|
|
46
|
+
"filename" : "AppIcon.png",
|
|
47
|
+
"idiom" : "universal",
|
|
48
|
+
"platform" : "ios",
|
|
49
|
+
"size" : "1024x1024"
|
|
50
|
+
}
|
|
51
|
+
],
|
|
52
|
+
"info" : {
|
|
53
|
+
"author" : "xcode",
|
|
54
|
+
"version" : 1
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
JSON
|
|
58
|
+
|
|
40
59
|
module_function
|
|
41
60
|
|
|
42
61
|
# Write a macOS-shaped 1024px RGBA master PNG (padding + squircle) from a
|
|
@@ -69,6 +88,30 @@ module Everywhere
|
|
|
69
88
|
true
|
|
70
89
|
end
|
|
71
90
|
|
|
91
|
+
# Write an iOS AppIcon.appiconset (Contents.json + one 1024px PNG) from a
|
|
92
|
+
# full-bleed source PNG. Unlike macOS, iOS masks the icon itself, so the
|
|
93
|
+
# artwork ships as a plain square — no inset, no squircle. The App Store
|
|
94
|
+
# rejects alpha channels, so the source is flattened onto `background`
|
|
95
|
+
# ([r, g, b] bytes, default white). Returns true, false if unreadable.
|
|
96
|
+
def write_ios_appiconset(source, dest_dir, background: nil)
|
|
97
|
+
raster = Raster.decode_png(source) or return false
|
|
98
|
+
|
|
99
|
+
# Fit the artwork inside the canvas preserving aspect ratio (square
|
|
100
|
+
# sources fill it exactly), then flatten onto the opaque backdrop.
|
|
101
|
+
fit = [CANVAS.to_f / raster.width, CANVAS.to_f / raster.height].min
|
|
102
|
+
sw = (raster.width * fit).round.clamp(1, CANVAS)
|
|
103
|
+
sh = (raster.height * fit).round.clamp(1, CANVAS)
|
|
104
|
+
|
|
105
|
+
canvas = Raster.transparent(CANVAS, CANVAS)
|
|
106
|
+
canvas.paste!(raster.resample(sw, sh), (CANVAS - sw) / 2, (CANVAS - sh) / 2)
|
|
107
|
+
out = canvas.flatten_onto(Array(background || [255, 255, 255])[0, 3])
|
|
108
|
+
|
|
109
|
+
FileUtils.mkdir_p(dest_dir)
|
|
110
|
+
out.write_png(File.join(dest_dir, "AppIcon.png"))
|
|
111
|
+
File.write(File.join(dest_dir, "Contents.json"), IOS_APPICON_CONTENTS)
|
|
112
|
+
true
|
|
113
|
+
end
|
|
114
|
+
|
|
72
115
|
# Build a macOS .icns from an already-shaped master PNG (ideally 1024px).
|
|
73
116
|
# Returns true on success.
|
|
74
117
|
def write_icns(master_png, dest)
|
|
@@ -117,6 +160,13 @@ module Everywhere
|
|
|
117
160
|
end
|
|
118
161
|
end
|
|
119
162
|
|
|
163
|
+
# "#RRGGBB" (or "RRGGBBAA" — alpha ignored) -> [r, g, b] bytes, nil if
|
|
164
|
+
# unparseable. For resolving appearance colors into flatten backdrops.
|
|
165
|
+
def hex_rgb(hex)
|
|
166
|
+
m = hex.to_s.strip.match(/\A#?(\h{2})(\h{2})(\h{2})/) or return nil
|
|
167
|
+
m.captures.map { |c| c.to_i(16) }
|
|
168
|
+
end
|
|
169
|
+
|
|
120
170
|
# PNG bytes of `master` at `size`x`size` (no-op resample when already sized).
|
|
121
171
|
def sized_png(master, size)
|
|
122
172
|
(size == master.width && size == master.height ? master : master.resample(size, size)).encode_png
|
|
@@ -27,8 +27,9 @@ module Everywhere
|
|
|
27
27
|
return :drop if line.empty?
|
|
28
28
|
|
|
29
29
|
case @profile
|
|
30
|
-
when :notarize
|
|
31
|
-
when :tebako
|
|
30
|
+
when :notarize then notarize(line)
|
|
31
|
+
when :tebako then tebako(line)
|
|
32
|
+
when :xcodebuild then xcodebuild(line)
|
|
32
33
|
end
|
|
33
34
|
end
|
|
34
35
|
|
|
@@ -127,6 +128,31 @@ module Everywhere
|
|
|
127
128
|
end
|
|
128
129
|
end
|
|
129
130
|
|
|
131
|
+
# --- xcodebuild (iOS shell) ----------------------------------------------
|
|
132
|
+
#
|
|
133
|
+
# xcodebuild echoes every compiler invocation and file path. Keep phases,
|
|
134
|
+
# errors and warnings; collapse the per-file spew into dim background.
|
|
135
|
+
def xcodebuild(line)
|
|
136
|
+
short = UI.short_path(line)
|
|
137
|
+
case line
|
|
138
|
+
when /(error|fatal error):/i, /\A\*\* BUILD FAILED \*\*/
|
|
139
|
+
" #{UI.red(short)}"
|
|
140
|
+
when /warning:/i
|
|
141
|
+
" #{UI.yellow(short)}"
|
|
142
|
+
when /\A\*\* BUILD SUCCEEDED \*\*/
|
|
143
|
+
UI.note_line("build succeeded", marker: "✓", color: :green)
|
|
144
|
+
when /\AResolve Package Graph/, /\AResolved source packages/
|
|
145
|
+
once(:spm) { UI.note_line("resolving Swift packages") }
|
|
146
|
+
when /\A(CompileSwift|SwiftCompile|CompileC|Ld|CodeSign|CopySwiftLibs|ProcessInfoPlistFile|CompileAssetCatalog|Touch|RegisterExecutionPolicyException|CreateBuildDirectory|SwiftDriver|SwiftEmitModule|ExtractAppIntentsMetadata|GenerateAssetSymbols|ProcessProductPackaging|Validate|MkDir|WriteAuxiliaryFile|Copy|PhaseScriptExecution|ClangStatCache|ScanDependencies|Planning|GatherProvisioningInputs|ComputePackagePrebuildTargetDependencies)\b/
|
|
147
|
+
phase = Regexp.last_match(1)
|
|
148
|
+
once("phase-#{phase}") { UI.note_line(phase.gsub(/(?<=[a-z])(?=[A-Z])/, " ").downcase) }
|
|
149
|
+
when /\Anote:/, /\A\s{4,}/, /\A(cd|export) /, /^$/
|
|
150
|
+
:drop
|
|
151
|
+
else
|
|
152
|
+
" #{UI.gray(short)}"
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
130
156
|
# Show a line only the first time its key is seen; :drop the repeats. Keeps a
|
|
131
157
|
# long notarization from scrolling a wall of identical "waiting" lines (and
|
|
132
158
|
# notarytool's thrice-echoed submission id from showing three times).
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "config"
|
|
4
|
+
|
|
5
|
+
module Everywhere
|
|
6
|
+
# Rack middleware serving the mobile shells' remote path configuration:
|
|
7
|
+
#
|
|
8
|
+
# GET /everywhere/ios_v1.json (android_v1.json when that shell lands)
|
|
9
|
+
#
|
|
10
|
+
# The JSON is built from config/everywhere.yml on each request (it's a tiny
|
|
11
|
+
# file, and re-reading means tab changes deploy with the app — the whole
|
|
12
|
+
# point: no app-store release to change tabs). This middleware is the
|
|
13
|
+
# SINATRA/HANAMI strategy — add it to config.ru:
|
|
14
|
+
#
|
|
15
|
+
# use Everywhere::MobileConfigEndpoint
|
|
16
|
+
#
|
|
17
|
+
# Rails apps don't need it: Everywhere::Engine appends a real route to
|
|
18
|
+
# MobileConfigsController instead (logging, instrumentation, overridable).
|
|
19
|
+
#
|
|
20
|
+
# The native shells load it as a Hotwire Native path-configuration source
|
|
21
|
+
# (after their bundled copy), so both rules and settings.tabs can be
|
|
22
|
+
# overridden server-side.
|
|
23
|
+
class MobileConfigEndpoint
|
|
24
|
+
PATH = %r{\A/everywhere/(ios|android)_v1\.json\z}
|
|
25
|
+
RESET = "/everywhere/reset"
|
|
26
|
+
|
|
27
|
+
def initialize(app, root: Dir.pwd)
|
|
28
|
+
@app = app
|
|
29
|
+
@root = root
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def call(env)
|
|
33
|
+
return @app.call(env) unless env["REQUEST_METHOD"] == "GET"
|
|
34
|
+
|
|
35
|
+
if env["PATH_INFO"] == RESET
|
|
36
|
+
require "rack/utils"
|
|
37
|
+
to = Rack::Utils.parse_query(env["QUERY_STRING"])["to"]
|
|
38
|
+
return html(Everywhere.mobile_reset_html(to))
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
match = PATH.match(env["PATH_INFO"]) or return @app.call(env)
|
|
42
|
+
|
|
43
|
+
os = match[1]
|
|
44
|
+
config = Config.load(@root)
|
|
45
|
+
tabs = Everywhere.resolve_tabs(config.tabs_for(os), request_for(env))
|
|
46
|
+
json(config.path_configuration_json(os, tabs: tabs))
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def json(body)
|
|
52
|
+
[200, { "content-type" => "application/json", "cache-control" => cache_control }, [body]]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def html(body)
|
|
56
|
+
[200, { "content-type" => "text/html; charset=utf-8", "cache-control" => "no-store" }, [body]]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# A Rack::Request the tab filter can read (session/cookies) when the host
|
|
60
|
+
# app has it; otherwise the raw env is enough for header-based checks.
|
|
61
|
+
def request_for(env)
|
|
62
|
+
require "rack/request"
|
|
63
|
+
Rack::Request.new(env)
|
|
64
|
+
rescue LoadError
|
|
65
|
+
env
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Never cache: the response depends on the session (filter_tabs), so a
|
|
69
|
+
# public cache could leak one user's tabs to another and a client cache
|
|
70
|
+
# would serve stale tabs across sign-in/out. It's a couple of ms anyway.
|
|
71
|
+
def cache_control
|
|
72
|
+
"no-store"
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "config"
|
|
4
|
+
|
|
5
|
+
module Everywhere
|
|
6
|
+
# Serves the mobile shells' remote path configuration in Rails apps:
|
|
7
|
+
#
|
|
8
|
+
# GET /everywhere/ios_v1.json -> MobileConfigsController#show
|
|
9
|
+
#
|
|
10
|
+
# The route is appended by Everywhere::Engine, so it shows up in
|
|
11
|
+
# `rails routes` and the app can override it by drawing its own. Inherits
|
|
12
|
+
# ActionController::API on purpose:
|
|
13
|
+
# * never the app's ApplicationController — its before_actions
|
|
14
|
+
# (authentication!) would lock the shells out, and
|
|
15
|
+
# * not ActionController::Base — its `helper :all` resolution reaches
|
|
16
|
+
# into the host app's helpers, which aren't loadable when the gem is
|
|
17
|
+
# required during boot. A JSON endpoint needs none of that.
|
|
18
|
+
#
|
|
19
|
+
# everywhere.yml is re-read on every request: tab changes go live with a
|
|
20
|
+
# deploy — or a plain file save in development, where responses are
|
|
21
|
+
# explicitly uncached so the dev shell's reload polling sees them instantly.
|
|
22
|
+
class MobileConfigsController < ActionController::API
|
|
23
|
+
def show
|
|
24
|
+
config = Config.load(::Rails.root.to_s)
|
|
25
|
+
|
|
26
|
+
# Never cache: the response depends on the session (filter_tabs can hide
|
|
27
|
+
# tabs when signed out), so a shared/public cache could serve one user's
|
|
28
|
+
# tabs to another, and a client cache would serve stale tabs across
|
|
29
|
+
# sign-in/out. The endpoint is a couple of milliseconds — no cache needed.
|
|
30
|
+
response.headers["cache-control"] = "no-store"
|
|
31
|
+
|
|
32
|
+
os = params[:platform_v1].delete_suffix("_v1")
|
|
33
|
+
tabs = Everywhere.resolve_tabs(config.tabs_for(os), request)
|
|
34
|
+
render json: config.path_configuration_hash(os, tabs: tabs)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# GET /everywhere/reset?to=/path — the native "reset the app" page. Auth
|
|
38
|
+
# flows redirect the shell here so it rebuilds cleanly before landing on
|
|
39
|
+
# `to`. Never cached (it must run every time).
|
|
40
|
+
def reset
|
|
41
|
+
response.headers["cache-control"] = "no-store"
|
|
42
|
+
render html: Everywhere.mobile_reset_html(params[:to]).html_safe,
|
|
43
|
+
layout: false, content_type: "text/html"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|