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
|
@@ -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
|
data/lib/everywhere/paths.rb
CHANGED
|
@@ -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
|
data/lib/everywhere/raster.rb
CHANGED
|
@@ -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)
|
data/lib/everywhere/shellout.rb
CHANGED
|
@@ -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)
|
data/lib/everywhere/version.rb
CHANGED
|
@@ -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,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>
|