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.
Files changed (59) hide show
  1. checksums.yaml +4 -4
  2. data/bridge/README.md +57 -2
  3. data/bridge/everywhere/bridge.js +803 -9
  4. data/bridge/package.json +1 -1
  5. data/lib/everywhere/builders/ios.rb +364 -0
  6. data/lib/everywhere/cli.rb +2 -0
  7. data/lib/everywhere/commands/build.rb +17 -1
  8. data/lib/everywhere/commands/clean.rb +19 -10
  9. data/lib/everywhere/commands/dev.rb +182 -19
  10. data/lib/everywhere/commands/doctor.rb +45 -3
  11. data/lib/everywhere/commands/icon.rb +14 -0
  12. data/lib/everywhere/commands/install.rb +37 -6
  13. data/lib/everywhere/commands/logs.rb +56 -0
  14. data/lib/everywhere/commands/platform/build.rb +3 -3
  15. data/lib/everywhere/commands/platform/runner.rb +1 -1
  16. data/lib/everywhere/commands/release.rb +1 -1
  17. data/lib/everywhere/commands/shell_dir.rb +11 -4
  18. data/lib/everywhere/config.rb +366 -1
  19. data/lib/everywhere/engine.rb +33 -1
  20. data/lib/everywhere/icon.rb +50 -0
  21. data/lib/everywhere/log_filter.rb +28 -2
  22. data/lib/everywhere/mobile_config_endpoint.rb +75 -0
  23. data/lib/everywhere/mobile_configs_controller.rb +46 -0
  24. data/lib/everywhere/native_helper.rb +156 -0
  25. data/lib/everywhere/paths.rb +41 -0
  26. data/lib/everywhere/raster.rb +17 -0
  27. data/lib/everywhere/shellout.rb +3 -1
  28. data/lib/everywhere/simulator.rb +74 -0
  29. data/lib/everywhere/ui.rb +18 -0
  30. data/lib/everywhere/version.rb +1 -1
  31. data/support/mobile/ios/App/App.xcconfig +6 -0
  32. data/support/mobile/ios/App/AppDelegate.swift +163 -0
  33. data/support/mobile/ios/App/Assets.xcassets/AccentColor.colorset/Contents.json +20 -0
  34. data/support/mobile/ios/App/Assets.xcassets/AppIcon.appiconset/AppIcon.png +0 -0
  35. data/support/mobile/ios/App/Assets.xcassets/AppIcon.appiconset/Contents.json +14 -0
  36. data/support/mobile/ios/App/Assets.xcassets/Contents.json +6 -0
  37. data/support/mobile/ios/App/Assets.xcassets/LaunchBackground.colorset/Contents.json +38 -0
  38. data/support/mobile/ios/App/Base.lproj/LaunchScreen.storyboard +32 -0
  39. data/support/mobile/ios/App/Bridge/BiometricsComponent.swift +276 -0
  40. data/support/mobile/ios/App/Bridge/HapticsComponent.swift +47 -0
  41. data/support/mobile/ios/App/Bridge/NotificationComponent.swift +56 -0
  42. data/support/mobile/ios/App/Bridge/PermissionsComponent.swift +142 -0
  43. data/support/mobile/ios/App/Bridge/StorageComponent.swift +63 -0
  44. data/support/mobile/ios/App/ErrorViewController.swift +64 -0
  45. data/support/mobile/ios/App/EverywhereConfig.swift +268 -0
  46. data/support/mobile/ios/App/EverywhereHost.swift +34 -0
  47. data/support/mobile/ios/App/Extensions/EverywhereExtensions.swift +32 -0
  48. data/support/mobile/ios/App/Info.plist +28 -0
  49. data/support/mobile/ios/App/Resources/everywhere.json +8 -0
  50. data/support/mobile/ios/App/Resources/path-configuration.json +19 -0
  51. data/support/mobile/ios/App/SceneDelegate.swift +443 -0
  52. data/support/mobile/ios/App.xcodeproj/project.pbxproj +454 -0
  53. data/support/mobile/ios/App.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
  54. data/support/mobile/ios/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +14 -0
  55. data/support/mobile/ios/App.xcodeproj/xcshareddata/xcschemes/App.xcscheme +77 -0
  56. data/support/mobile/ios/NativeExtensions/Package.swift +26 -0
  57. data/support/mobile/ios/NativeExtensions/Sources/NativeExtensions/Exports.swift +5 -0
  58. data/support/mobile/ios/README.md +66 -0
  59. metadata +35 -1
@@ -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
- "updates" => updates_shell_hash
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
 
@@ -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
- app.config.assets.precompile += %w[everywhere/bridge.js] if app.config.assets.respond_to?(:precompile)
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
@@ -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 then notarize(line)
31
- when :tebako then tebako(line)
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