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
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb"
4
+ require "json"
5
+
6
+ module Everywhere
7
+ # Server-side view helpers mirroring the JS bridge's platform detection, so
8
+ # ERB can branch on the native shell the same way `Everywhere.platform`
9
+ # does on the client:
10
+ #
11
+ # <%= link_to "Install the app", … unless native_app? %>
12
+ # <div class="<%= "native-inset" if native_app? %>">
13
+ # <% if native_version && native_version >= Gem::Version.new("1.2") %>
14
+ #
15
+ # Detection is by User-Agent: the RubyEverywhere shells prepend
16
+ # "RubyEverywhere/<version> (<os>)" to Hotwire Native's own
17
+ # "Hotwire Native iOS/Android" marker. Included into all Rails views by
18
+ # Everywhere::Engine.
19
+ module NativeHelper
20
+ # True inside any RubyEverywhere native shell (iOS or Android).
21
+ def native_app?
22
+ !native_platform.nil?
23
+ end
24
+
25
+ # :ios, :android, or nil in a plain browser. Reads Hotwire Native's UA
26
+ # marker, so it's correct even before the JS bridge has booted.
27
+ def native_platform
28
+ ua = _everywhere_user_agent or return nil
29
+ return :ios if ua.include?("Hotwire Native iOS")
30
+ return :android if ua.include?("Hotwire Native Android")
31
+
32
+ nil
33
+ end
34
+
35
+ # The shell's version as a Gem::Version (from the "RubyEverywhere/<ver>"
36
+ # UA prefix), or nil outside the shell / when unparseable. Use it to gate
37
+ # features that need a newer shell than some users have installed.
38
+ def native_version
39
+ ua = _everywhere_user_agent or return nil
40
+ match = ua.match(%r{RubyEverywhere/([\w.\-]+)}) or return nil
41
+
42
+ Gem::Version.new(match[1])
43
+ rescue ArgumentError
44
+ nil
45
+ end
46
+
47
+ # Where to send the user after an auth change. In a native shell, route
48
+ # through the reset page (/everywhere/reset) so the app rebuilds cleanly —
49
+ # fresh web views, re-fetched tabs — then lands on `to`. In a browser it's
50
+ # just `to`. Use it in controllers:
51
+ #
52
+ # redirect_to everywhere_auth_redirect(after_authentication_url)
53
+ #
54
+ # `to` may be a full URL or a path; only the same-origin path is forwarded.
55
+ def everywhere_auth_redirect(to)
56
+ return to unless native_app? && respond_to?(:everywhere_reset_path)
57
+
58
+ path = to.to_s.sub(%r{\Ahttps?://[^/]+}, "")
59
+ path = "/" if path.empty? || !path.start_with?("/")
60
+ everywhere_reset_path(to: path)
61
+ end
62
+
63
+ # App icon badge count, server-rendered. Emits a meta tag the JS bridge
64
+ # applies on every Turbo visit — CSP-safe (no inline script) and correct
65
+ # on the first paint. 0 clears the badge. Put it in the layout <head>:
66
+ #
67
+ # <%= everywhere_badge Current.user.unread_count %>
68
+ #
69
+ # In the mobile shell this badges the app icon; in an installed PWA the
70
+ # Badging API; elsewhere it renders inert metadata.
71
+ def everywhere_badge(count)
72
+ tag.meta(name: "everywhere:badge", content: count.to_i)
73
+ end
74
+
75
+ # Native tab bar badge for the tab whose everywhere.yml path is `path`.
76
+ # Same delivery as everywhere_badge; 0 clears. Mobile shell only.
77
+ #
78
+ # <%= everywhere_tab_badge "/inbox", Current.user.unread_count %>
79
+ def everywhere_tab_badge(path, count)
80
+ tag.meta(name: "everywhere:tab-badge",
81
+ content: JSON.generate({ path: path, count: count.to_i }))
82
+ end
83
+
84
+ # Biometric gate: wraps sensitive markup so the mobile shell keeps it
85
+ # hidden until Face ID / Touch ID passes — but only when the user has the
86
+ # device-local lock turned on (everywhere_biometric_toggle, or
87
+ # Everywhere.biometrics.setLockEnabled). Browsers and the desktop shell
88
+ # render the content plainly. Requires `biometrics` in everywhere.yml's
89
+ # permissions.
90
+ #
91
+ # <%= everywhere_biometric_lock reason: "Unlock account settings" do %>
92
+ # ...profile, password, sessions...
93
+ # <% end %>
94
+ #
95
+ # `name` keys the once-per-session unlock (defaults to the page path).
96
+ # For a fully custom overlay, hand-write the contract this emits:
97
+ #
98
+ # <div data-everywhere-biometric-lock="<name>"
99
+ # data-everywhere-biometric-reason="…" data-everywhere-biometric-passcode>
100
+ # <div data-everywhere-biometric-content hidden>…</div>
101
+ # <div data-everywhere-biometric-locked hidden>
102
+ # <button data-everywhere-biometric-unlock>Unlock</button>
103
+ # </div>
104
+ # </div>
105
+ def everywhere_biometric_lock(reason: nil, name: nil, allow_passcode: true,
106
+ unlock_label: "Unlock", locked_message: nil,
107
+ wrapper_class: nil, locked_class: nil,
108
+ message_class: nil, unlock_class: nil, &block)
109
+ content = respond_to?(:capture) ? capture(&block) : block.call
110
+ return content unless native_app?
111
+
112
+ attrs = [%(data-everywhere-biometric-lock="#{_everywhere_attr(name)}")]
113
+ attrs << %(data-everywhere-biometric-reason="#{_everywhere_attr(reason)}") if reason
114
+ attrs << "data-everywhere-biometric-passcode" if allow_passcode
115
+ attrs << %(class="#{_everywhere_attr(wrapper_class)}") if wrapper_class
116
+
117
+ html = +"<div #{attrs.join(" ")}>"
118
+ html << %(<div data-everywhere-biometric-content hidden>#{content}</div>)
119
+ html << %(<div data-everywhere-biometric-locked hidden#{%( class="#{_everywhere_attr(locked_class)}") if locked_class}>)
120
+ html << %(<p#{%( class="#{_everywhere_attr(message_class)}") if message_class}>#{_everywhere_attr(locked_message)}</p>) if locked_message
121
+ html << %(<button type="button" data-everywhere-biometric-unlock#{%( class="#{_everywhere_attr(unlock_class)}") if unlock_class}>#{_everywhere_attr(unlock_label)}</button>)
122
+ html << "</div></div>"
123
+ html.respond_to?(:html_safe) ? html.html_safe : html
124
+ end
125
+
126
+ # The settings switch for the device-local biometric lock. Renders hidden
127
+ # and disabled; the bridge reveals and enables it only in the mobile shell
128
+ # with working biometrics (toggling runs the biometric check first). Put
129
+ # `data-everywhere-biometric-toggle-row` + `hidden` on the surrounding row
130
+ # to reveal the whole thing together:
131
+ #
132
+ # <div data-everywhere-biometric-toggle-row hidden>
133
+ # <label>Require Face ID <%= everywhere_biometric_toggle %></label>
134
+ # </div>
135
+ def everywhere_biometric_toggle(css_class: nil, id: nil)
136
+ attrs = +""
137
+ attrs << %( id="#{_everywhere_attr(id)}") if id
138
+ attrs << %( class="#{_everywhere_attr(css_class)}") if css_class
139
+ html = %(<input type="checkbox" hidden disabled data-everywhere-biometric-toggle#{attrs}>)
140
+ html.respond_to?(:html_safe) ? html.html_safe : html
141
+ end
142
+
143
+ private
144
+
145
+ def _everywhere_attr(value)
146
+ ERB::Util.html_escape(value.to_s)
147
+ end
148
+
149
+ def _everywhere_user_agent
150
+ return unless respond_to?(:request) && request
151
+
152
+ ua = request.user_agent
153
+ ua unless ua.nil? || ua.empty?
154
+ end
155
+ end
156
+ end
@@ -33,11 +33,52 @@ module Everywhere
33
33
  "reinstall ruby_everywhere or pass --shell-dir")
34
34
  end
35
35
 
36
+ # The Hotwire Native iOS shell template bundled inside the gem:
37
+ # support/mobile/ios/ holds a frozen Xcode project the CLI stamps per-app
38
+ # (xcconfig + everywhere.json + AppIcon — never the pbxproj). The mobile/
39
+ # parent leaves room for support/mobile/android later.
40
+ def bundled_ios_dir
41
+ File.join(gem_root, "support", "mobile", "ios")
42
+ end
43
+
44
+ # Absolute path to the iOS template, or nil if the bundled copy is missing
45
+ # (a corrupt/partial install).
46
+ def ios_dir
47
+ bundled_ios_dir if File.exist?(File.join(bundled_ios_dir, "App.xcodeproj", "project.pbxproj"))
48
+ end
49
+
50
+ # Like #ios_dir but aborts with a helpful message when the template is
51
+ # missing. The --template-dir hint covers dev on a modified template.
52
+ def ios_dir!
53
+ ios_dir or UI.die!("couldn't find the bundled iOS template at #{bundled_ios_dir}; " \
54
+ "reinstall ruby_everywhere or pass --template-dir")
55
+ end
56
+
36
57
  # Per-user cache dir. Keeps heavy build state out of the gem and the app.
37
58
  def cache_dir
38
59
  File.expand_path(ENV["RUBYEVERYWHERE_HOME"] || "~/.rubyeverywhere")
39
60
  end
40
61
 
62
+ # The stamped copy of the iOS template for one app. Persistent (not a
63
+ # tmpdir) on purpose: a stable project path keeps Xcode's incremental state
64
+ # valid across builds, and users can open the stamped project in Xcode.
65
+ def ios_work_dir(bundle_id)
66
+ File.join(cache_dir, "ios", bundle_id)
67
+ end
68
+
69
+ # Where xcodebuild writes DerivedData for iOS shell builds. Shared across
70
+ # apps like cargo_target_dir — safe because the product is always App.app;
71
+ # worst case a mismatch is a cache miss, never a wrong artifact.
72
+ def ios_derived_data_dir
73
+ File.join(cache_dir, "ios-derived-data")
74
+ end
75
+
76
+ # Where SPM clones the shell's package dependencies (hotwire-native-ios),
77
+ # so resolution is warm/offline after the first build.
78
+ def ios_packages_dir
79
+ File.join(cache_dir, "ios-packages")
80
+ end
81
+
41
82
  # Where cargo writes the shell's build output (CARGO_TARGET_DIR). Redirected
42
83
  # here so cargo never dumps a multi-GB target/ into the installed gem, and so
43
84
  # the shell compiles warm across projects — the shell source is identical for
@@ -25,6 +25,23 @@ module Everywhere
25
25
  new(width, height)
26
26
  end
27
27
 
28
+ # A copy composited over an opaque background color ([r, g, b] bytes) —
29
+ # straight alpha blend, result fully opaque. Flattens icons for stores
30
+ # that reject alpha channels (the iOS App Store).
31
+ def flatten_onto(rgb)
32
+ out = "\x00".b * (width * height * 4)
33
+ (width * height).times do |p|
34
+ i = p * 4
35
+ a = pixels.getbyte(i + 3)
36
+ 3.times do |c|
37
+ out.setbyte(i + c, a == 255 ? pixels.getbyte(i + c)
38
+ : ((pixels.getbyte(i + c) * a + rgb[c] * (255 - a)) / 255.0).round)
39
+ end
40
+ out.setbyte(i + 3, 255)
41
+ end
42
+ self.class.new(width, height, out)
43
+ end
44
+
28
45
  # Decode a PNG file into a Raster, or nil if it's a flavor PNG.decode_rgba
29
46
  # can't read (palette, 16-bit, interlaced, grayscale).
30
47
  def self.decode_png(path)
@@ -72,8 +72,10 @@ module Everywhere
72
72
  end
73
73
  private_class_method :emit
74
74
 
75
+ # pgroup: the child leads its own process group so teardown can TERM the
76
+ # whole tree (foreman + watchers) without signalling the CLI itself.
75
77
  def spawn(env, cmd, chdir:)
76
- unbundled { Process.spawn(env, cmd, chdir: chdir) }
78
+ unbundled { Process.spawn(env, cmd, chdir: chdir, pgroup: true) }
77
79
  end
78
80
  end
79
81
  end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "shellout"
5
+ require_relative "ui"
6
+
7
+ module Everywhere
8
+ # Thin wrappers around `xcrun simctl` — just enough to boot a Simulator,
9
+ # install the stamped shell and launch it. Anything fancier (choosing
10
+ # devices, multiple simulators) belongs in the user's own Xcode workflow.
11
+ module Simulator
12
+ module_function
13
+
14
+ # UDID of a booted iPhone Simulator, or nil.
15
+ def booted_udid
16
+ devices.find { |d| d["state"] == "Booted" }&.fetch("udid", nil)
17
+ end
18
+
19
+ # Boot the newest available iPhone (or reuse a booted one) and bring the
20
+ # Simulator app to the foreground. Returns the UDID.
21
+ def boot_default!
22
+ if (udid = booted_udid)
23
+ Shellout.run?("open", "-a", "Simulator", quiet: true)
24
+ return udid
25
+ end
26
+
27
+ device = devices.reverse.find { |d| d["name"].to_s.match?(/\AiPhone/) } || devices.last
28
+ UI.die!("no iOS Simulator devices — open Xcode once to install a Simulator runtime") unless device
29
+
30
+ UI.step("booting simulator #{UI.bold(device["name"])}")
31
+ Shellout.run?("xcrun", "simctl", "boot", device["udid"], quiet: true) # races `open` starting it; harmless
32
+ Shellout.run?("open", "-a", "Simulator", quiet: true)
33
+ wait_until_booted(device["udid"])
34
+ device["udid"]
35
+ end
36
+
37
+ def install(udid, app_path)
38
+ out, status = Shellout.capture("xcrun", "simctl", "install", udid, app_path)
39
+ UI.die!("simctl install failed: #{out.strip}") unless status&.success?
40
+ end
41
+
42
+ # Launch (relaunching if already running). Env vars are forwarded into the
43
+ # app's process via simctl's SIMCTL_CHILD_ prefix — how the dev URL
44
+ # reaches the shell without restamping the bundle.
45
+ def launch(udid, bundle_id, env: {})
46
+ child_env = env.transform_keys { |k| "SIMCTL_CHILD_#{k}" }
47
+ out, status = Shellout.capture(child_env,
48
+ "xcrun", "simctl", "launch",
49
+ "--terminate-running-process", udid, bundle_id)
50
+ UI.die!("simctl launch failed: #{out.strip}") unless status&.success?
51
+ end
52
+
53
+ # Available devices of the current runtime set, oldest runtime first —
54
+ # `.last` / `reverse.find` therefore prefer the newest.
55
+ def devices
56
+ out, status = Shellout.capture("xcrun", "simctl", "list", "devices", "available", "-j")
57
+ return [] unless status&.success?
58
+
59
+ JSON.parse(out).fetch("devices", {}).sort.flat_map { |_runtime, list| list }
60
+ rescue JSON::ParserError
61
+ []
62
+ end
63
+
64
+ def wait_until_booted(udid, timeout: 60)
65
+ deadline = Time.now + timeout
66
+ until Time.now > deadline
67
+ return if devices.any? { |d| d["udid"] == udid && d["state"] == "Booted" }
68
+
69
+ sleep 1
70
+ end
71
+ UI.die!("simulator never finished booting")
72
+ end
73
+ end
74
+ end
data/lib/everywhere/ui.rb CHANGED
@@ -31,6 +31,24 @@ module Everywhere
31
31
  def cyan(t) = paint(t, 36)
32
32
  def gray(t) = paint(t, 90) # bright black — dimmer than dim() on most themes
33
33
 
34
+ # Display casing for os tokens. Target IDs ("macos-arm64") stay lowercase
35
+ # wherever they're typed or stored; use these only in human-facing output.
36
+ OS_LABELS = {
37
+ "macos" => "macOS",
38
+ "ios" => "iOS",
39
+ "android" => "Android",
40
+ "windows" => "Windows",
41
+ "linux" => "Linux"
42
+ }.freeze
43
+
44
+ def os_label(os) = OS_LABELS.fetch(os.to_s, os.to_s)
45
+
46
+ # "macos-arm64" → "macOS-arm64"
47
+ def target_label(target)
48
+ os, arch = target.to_s.split("-", 2)
49
+ [ os_label(os), arch ].compact.join("-")
50
+ end
51
+
34
52
  # --- output levels --------------------------------------------------------
35
53
  #
36
54
  # phase ● a major phase boundary (build / sign / notarize)
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Everywhere
4
- VERSION = "0.1.15"
4
+ VERSION = "0.2.0"
5
5
 
6
6
  # Version of the @rubyeverywhere/bridge JS this gem ships. bridge/ in the
7
7
  # gem IS the npm package (served to Rails apps by Everywhere::Engine,
@@ -0,0 +1,6 @@
1
+ // Overwritten by `every build --target ios-*` on every stamp — do not edit by hand.
2
+ PRODUCT_BUNDLE_IDENTIFIER = com.rubyeverywhere.app
3
+ MARKETING_VERSION = 0.1.0
4
+ CURRENT_PROJECT_VERSION = 1
5
+ INFOPLIST_KEY_CFBundleDisplayName = RubyEverywhere
6
+ DEVELOPMENT_TEAM =
@@ -0,0 +1,163 @@
1
+ import HotwireNative
2
+ import UIKit
3
+ import UserNotifications
4
+ import WebKit
5
+
6
+ @main
7
+ class AppDelegate: UIResponder, UIApplicationDelegate {
8
+ func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
9
+ configureAppearance()
10
+ configureHotwire()
11
+ UNUserNotificationCenter.current().delegate = self
12
+ return true
13
+ }
14
+
15
+ // MARK: UISceneSession Lifecycle
16
+
17
+ func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
18
+ UISceneConfiguration(name: "Default", sessionRole: connectingSceneSession.role)
19
+ }
20
+
21
+ // MARK: Configuration
22
+
23
+ private func configureAppearance() {
24
+ // Make navigation bars opaque.
25
+ UINavigationBar.appearance().scrollEdgeAppearance = .init()
26
+ }
27
+
28
+ private func configureHotwire() {
29
+ let config = EverywhereConfig.shared
30
+
31
+ // The path configuration (rules + auth-gated tabs) is loaded by the
32
+ // SceneDelegate once a scene connects — it mirrors the web view's
33
+ // cookies into the shared store first, so the auth-gated tab list is
34
+ // fetched as the signed-in user (see SceneDelegate.loadPathConfiguration).
35
+
36
+ Hotwire.config.applicationUserAgentPrefix = config.userAgentPrefix
37
+ Hotwire.config.backButtonDisplayMode = .minimal
38
+ Hotwire.config.showDoneButtonOnModals = true
39
+ #if DEBUG
40
+ Hotwire.config.debugLoggingEnabled = true
41
+ #endif
42
+
43
+ Hotwire.registerBridgeComponents([
44
+ NotificationComponent.self,
45
+ HapticsComponent.self,
46
+ PermissionsComponent.self,
47
+ BiometricsComponent.self,
48
+ StorageComponent.self
49
+ ] + EverywhereExtensions.components)
50
+
51
+ // Handles Everywhere.reloadTabs() from the page (a dedicated message
52
+ // handler, so it works regardless of which bridge components are
53
+ // mounted). Posts a notification the SceneDelegate acts on.
54
+ let controlHandler = WebControlHandler()
55
+
56
+ // Mirrors the framework's default web view factory (WKWebView.debugInspectable):
57
+ // the configuration we receive already carries Hotwire's process pool and
58
+ // user agent, so we only add our user script before creating the web view.
59
+ Hotwire.config.makeCustomWebView = { configuration in
60
+ // Expose the app's identity to the page before any of its scripts
61
+ // run. Built here, per web view: webConfigJSON carries the
62
+ // instance override, which changes when a picker re-roots the app.
63
+ let configScript = WKUserScript(
64
+ source: "window.__EVERYWHERE_CONFIG__ = \(config.webConfigJSON);",
65
+ injectionTime: .atDocumentStart,
66
+ forMainFrameOnly: true
67
+ )
68
+ configuration.userContentController.addUserScript(configScript)
69
+ // Re-add defensively: a reused configuration would throw on a
70
+ // duplicate handler name.
71
+ configuration.userContentController.removeScriptMessageHandler(forName: "everywhereControl")
72
+ configuration.userContentController.add(controlHandler, name: "everywhereControl")
73
+ let webView = WKWebView(frame: .zero, configuration: configuration)
74
+ #if DEBUG
75
+ if #available(iOS 16.4, *) {
76
+ webView.isInspectable = true
77
+ }
78
+ #endif
79
+ return webView
80
+ }
81
+ }
82
+ }
83
+
84
+ // MARK: - Page → shell control messages
85
+
86
+ extension Notification.Name {
87
+ static let everywhereReloadConfig = Notification.Name("EverywhereReloadConfig")
88
+ static let everywhereResetApp = Notification.Name("EverywhereResetApp")
89
+ static let everywhereSetTabBadge = Notification.Name("EverywhereSetTabBadge")
90
+ static let everywhereSetInstance = Notification.Name("EverywhereSetInstance")
91
+ static let everywhereClearInstance = Notification.Name("EverywhereClearInstance")
92
+ /// Posted by `everywhereVisit(_:)` from native extension code.
93
+ static let everywhereNativeVisit = Notification.Name("EverywhereNativeVisit")
94
+ }
95
+
96
+ /// Receives `window.webkit.messageHandlers.everywhereControl` messages and
97
+ /// turns them into app-wide notifications. Holds no references to the web view
98
+ /// or delegates, so retaining it on the content controller can't cycle.
99
+ /// { action: "reloadConfig" } → refresh tabs/path config
100
+ /// { action: "reset", to: "/path" } → full app reset, then land on `to`
101
+ /// { action: "setBadge", count: 3 } → app icon badge (0 clears)
102
+ /// { action: "setTabBadge", path: "/x", count: 3 } → tab bar badge (0 clears)
103
+ /// { action: "setInstance", url: "https://…", to: "/path"? } → persist instance root + reset
104
+ /// { action: "clearInstance", to: "/path"? } → back to the stamped root + reset
105
+ final class WebControlHandler: NSObject, WKScriptMessageHandler {
106
+ func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) {
107
+ guard let body = message.body as? [String: Any],
108
+ let action = body["action"] as? String
109
+ else { return }
110
+
111
+ #if DEBUG
112
+ NSLog("everywhereControl: %@", String(describing: body))
113
+ #endif
114
+
115
+ switch action {
116
+ case "reloadConfig":
117
+ NotificationCenter.default.post(name: .everywhereReloadConfig, object: nil)
118
+ case "reset":
119
+ let info = (body["to"] as? String).map { ["to": $0] }
120
+ NotificationCenter.default.post(name: .everywhereResetApp, object: nil, userInfo: info)
121
+ case "setBadge":
122
+ setAppBadge((body["count"] as? NSNumber)?.intValue ?? 0)
123
+ case "setTabBadge":
124
+ guard let path = body["path"] as? String else { return }
125
+ NotificationCenter.default.post(
126
+ name: .everywhereSetTabBadge, object: nil,
127
+ userInfo: ["path": path, "count": (body["count"] as? NSNumber)?.intValue ?? 0])
128
+ case "setInstance":
129
+ guard let url = body["url"] as? String else { return }
130
+ var info: [String: Any] = ["url": url]
131
+ if let to = body["to"] as? String { info["to"] = to }
132
+ NotificationCenter.default.post(name: .everywhereSetInstance, object: nil, userInfo: info)
133
+ case "clearInstance":
134
+ let info = (body["to"] as? String).map { ["to": $0] }
135
+ NotificationCenter.default.post(name: .everywhereClearInstance, object: nil, userInfo: info)
136
+ default:
137
+ break
138
+ }
139
+ }
140
+
141
+ /// Provisional authorization is silent — no permission prompt for a badge.
142
+ private func setAppBadge(_ count: Int) {
143
+ let center = UNUserNotificationCenter.current()
144
+ center.requestAuthorization(options: [.badge, .provisional]) { granted, _ in
145
+ guard granted else { return }
146
+ if #available(iOS 16.0, *) {
147
+ center.setBadgeCount(count)
148
+ } else {
149
+ DispatchQueue.main.async {
150
+ UIApplication.shared.applicationIconBadgeNumber = count
151
+ }
152
+ }
153
+ }
154
+ }
155
+ }
156
+
157
+ // MARK: - UNUserNotificationCenterDelegate
158
+
159
+ extension AppDelegate: UNUserNotificationCenterDelegate {
160
+ func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
161
+ completionHandler([.banner, .sound])
162
+ }
163
+ }
@@ -0,0 +1,20 @@
1
+ {
2
+ "colors" : [
3
+ {
4
+ "color" : {
5
+ "color-space" : "srgb",
6
+ "components" : {
7
+ "alpha" : "1.000",
8
+ "blue" : "0x2D",
9
+ "green" : "0x34",
10
+ "red" : "0xCC"
11
+ }
12
+ },
13
+ "idiom" : "universal"
14
+ }
15
+ ],
16
+ "info" : {
17
+ "author" : "xcode",
18
+ "version" : 1
19
+ }
20
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "images" : [
3
+ {
4
+ "filename" : "AppIcon.png",
5
+ "idiom" : "universal",
6
+ "platform" : "ios",
7
+ "size" : "1024x1024"
8
+ }
9
+ ],
10
+ "info" : {
11
+ "author" : "xcode",
12
+ "version" : 1
13
+ }
14
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "info" : {
3
+ "author" : "xcode",
4
+ "version" : 1
5
+ }
6
+ }
@@ -0,0 +1,38 @@
1
+ {
2
+ "colors" : [
3
+ {
4
+ "color" : {
5
+ "color-space" : "srgb",
6
+ "components" : {
7
+ "alpha" : "1.000",
8
+ "blue" : "0xF7",
9
+ "green" : "0xF9",
10
+ "red" : "0xFA"
11
+ }
12
+ },
13
+ "idiom" : "universal"
14
+ },
15
+ {
16
+ "appearances" : [
17
+ {
18
+ "appearance" : "luminosity",
19
+ "value" : "dark"
20
+ }
21
+ ],
22
+ "color" : {
23
+ "color-space" : "srgb",
24
+ "components" : {
25
+ "alpha" : "1.000",
26
+ "blue" : "0x1A",
27
+ "green" : "0x1B",
28
+ "red" : "0x1C"
29
+ }
30
+ },
31
+ "idiom" : "universal"
32
+ }
33
+ ],
34
+ "info" : {
35
+ "author" : "xcode",
36
+ "version" : 1
37
+ }
38
+ }
@@ -0,0 +1,32 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="22505" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
3
+ <device id="retina6_12" orientation="portrait" appearance="light"/>
4
+ <dependencies>
5
+ <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="22504"/>
6
+ <capability name="Named colors" minToolsVersion="9.0"/>
7
+ <capability name="Safe area layout guides" minToolsVersion="9.0"/>
8
+ <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
9
+ </dependencies>
10
+ <scenes>
11
+ <!--View Controller-->
12
+ <scene sceneID="EHf-IW-A2E">
13
+ <objects>
14
+ <viewController id="01J-lp-oVM" sceneMemberID="viewController">
15
+ <view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
16
+ <rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
17
+ <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
18
+ <viewLayoutGuide key="safeArea" id="6Tk-OE-BBY"/>
19
+ <color key="backgroundColor" name="LaunchBackground"/>
20
+ </view>
21
+ </viewController>
22
+ <placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
23
+ </objects>
24
+ <point key="canvasLocation" x="53" y="375"/>
25
+ </scene>
26
+ </scenes>
27
+ <resources>
28
+ <namedColor name="LaunchBackground">
29
+ <color red="0.98039215686274506" green="0.97647058823529409" blue="0.96862745098039216" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
30
+ </namedColor>
31
+ </resources>
32
+ </document>