ruby_everywhere 0.1.15 → 0.3.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 +105 -2
- data/bridge/everywhere/bridge.js +1131 -9
- data/bridge/everywhere/native.css +203 -0
- data/bridge/package.json +6 -3
- data/lib/everywhere/builders/ios.rb +396 -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 +469 -1
- data/lib/everywhere/engine.rb +42 -1
- data/lib/everywhere/icon.rb +50 -0
- data/lib/everywhere/log_filter.rb +28 -2
- data/lib/everywhere/mobile_config_endpoint.rb +88 -0
- data/lib/everywhere/mobile_configs_controller.rb +64 -0
- data/lib/everywhere/native_helper.rb +352 -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 +164 -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/MenuComponent.swift +192 -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 +289 -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 +484 -0
- data/support/mobile/ios/App.xcodeproj/project.pbxproj +458 -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 +73 -0
- metadata +37 -1
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/* @rubyeverywhere/bridge — safe-area utilities for the native shells.
|
|
2
|
+
*
|
|
3
|
+
* Pair with <meta name="viewport" content="…, viewport-fit=cover"> so the
|
|
4
|
+
* browser exposes the device's safe-area insets. These classes pad content
|
|
5
|
+
* clear of the status bar / Dynamic Island / home indicator without
|
|
6
|
+
* hardcoding device sizes. On the desktop shell and plain browsers the insets
|
|
7
|
+
* are 0, so the classes are inert — safe to apply unconditionally.
|
|
8
|
+
*
|
|
9
|
+
* Include in Rails: <%= stylesheet_link_tag "everywhere/native" %>
|
|
10
|
+
* (Sinatra/Hanami: it's vendored to public/ by `every install`.)
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
:root {
|
|
14
|
+
--everywhere-inset-top: env(safe-area-inset-top, 0px);
|
|
15
|
+
--everywhere-inset-right: env(safe-area-inset-right, 0px);
|
|
16
|
+
--everywhere-inset-bottom: env(safe-area-inset-bottom, 0px);
|
|
17
|
+
--everywhere-inset-left: env(safe-area-inset-left, 0px);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/* Padding utilities — add safe-area room on top of any existing padding. */
|
|
21
|
+
.native-inset {
|
|
22
|
+
padding-top: calc(var(--everywhere-inset-top) + var(--native-inset-pad, 0px));
|
|
23
|
+
padding-bottom: calc(var(--everywhere-inset-bottom) + var(--native-inset-pad, 0px));
|
|
24
|
+
}
|
|
25
|
+
.native-inset-top { padding-top: calc(var(--everywhere-inset-top) + var(--native-inset-pad, 0px)); }
|
|
26
|
+
.native-inset-bottom { padding-bottom: calc(var(--everywhere-inset-bottom) + var(--native-inset-pad, 0px)); }
|
|
27
|
+
.native-inset-left { padding-left: calc(var(--everywhere-inset-left) + var(--native-inset-pad, 0px)); }
|
|
28
|
+
.native-inset-right { padding-right: calc(var(--everywhere-inset-right) + var(--native-inset-pad, 0px)); }
|
|
29
|
+
|
|
30
|
+
/* Margin variants, when padding would fight an existing background. */
|
|
31
|
+
.native-inset-mt { margin-top: var(--everywhere-inset-top); }
|
|
32
|
+
.native-inset-mb { margin-bottom: var(--everywhere-inset-bottom); }
|
|
33
|
+
|
|
34
|
+
/* A bar pinned to the bottom edge that stays above the home indicator. */
|
|
35
|
+
.native-safe-bottom {
|
|
36
|
+
padding-bottom: max(var(--everywhere-inset-bottom), var(--native-safe-min, 0.5rem));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/* Fixed/absolute elements anchored near the bottom (toasts, nudges, FABs):
|
|
40
|
+
* lifts them clear of the home indicator AND anything overlaying the webview
|
|
41
|
+
* from below — on iOS the floating tab bar contributes to the bottom inset.
|
|
42
|
+
* --native-bottom-gap is the visual gap above that (default 1rem). */
|
|
43
|
+
.native-bottom {
|
|
44
|
+
bottom: calc(var(--everywhere-inset-bottom) + var(--native-bottom-gap, 1rem));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/* Full-height element that respects both vertical insets. */
|
|
48
|
+
.native-safe-height {
|
|
49
|
+
min-height: calc(100dvh - var(--everywhere-inset-top) - var(--everywhere-inset-bottom));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/* ---------------------------------------------------------------------------
|
|
53
|
+
* Native chrome — styling for the everywhere_fab / everywhere_menu helpers and
|
|
54
|
+
* the bridge's DOM action-sheet fallback. Set --everywhere-tint in your own CSS
|
|
55
|
+
* to brand the accent (it defaults to the platform accent color).
|
|
56
|
+
* ------------------------------------------------------------------------- */
|
|
57
|
+
|
|
58
|
+
:root {
|
|
59
|
+
--everywhere-tint: AccentColor;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/* Floating action button (everywhere_fab): fixed, safe-area-aware, above the
|
|
63
|
+
* home indicator and iOS floating tab bar. A real link/button, so it shows in
|
|
64
|
+
* a browser too. */
|
|
65
|
+
.everywhere-fab {
|
|
66
|
+
position: fixed;
|
|
67
|
+
right: max(var(--everywhere-inset-right), 1rem);
|
|
68
|
+
bottom: calc(var(--everywhere-inset-bottom) + var(--native-bottom-gap, 1rem));
|
|
69
|
+
z-index: 1000;
|
|
70
|
+
display: inline-flex;
|
|
71
|
+
align-items: center;
|
|
72
|
+
justify-content: center;
|
|
73
|
+
gap: 0.5rem;
|
|
74
|
+
width: 3.5rem;
|
|
75
|
+
height: 3.5rem;
|
|
76
|
+
padding: 0;
|
|
77
|
+
border: 0;
|
|
78
|
+
border-radius: 9999px;
|
|
79
|
+
background: var(--everywhere-fab-bg, var(--everywhere-tint, #1a73e8));
|
|
80
|
+
color: var(--everywhere-fab-color, #fff);
|
|
81
|
+
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.28);
|
|
82
|
+
cursor: pointer;
|
|
83
|
+
-webkit-tap-highlight-color: transparent;
|
|
84
|
+
transition: transform 0.1s ease, box-shadow 0.1s ease;
|
|
85
|
+
}
|
|
86
|
+
.everywhere-fab:active {
|
|
87
|
+
transform: scale(0.94);
|
|
88
|
+
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.3);
|
|
89
|
+
}
|
|
90
|
+
.everywhere-fab-left {
|
|
91
|
+
right: auto;
|
|
92
|
+
left: max(var(--everywhere-inset-left), 1rem);
|
|
93
|
+
}
|
|
94
|
+
/* Extended FAB: pill with a visible label beside the icon. */
|
|
95
|
+
.everywhere-fab-extended {
|
|
96
|
+
width: auto;
|
|
97
|
+
height: 3.25rem;
|
|
98
|
+
padding: 0 1.25rem;
|
|
99
|
+
font: inherit;
|
|
100
|
+
font-weight: 600;
|
|
101
|
+
}
|
|
102
|
+
.everywhere-fab-icon {
|
|
103
|
+
width: 1.5rem;
|
|
104
|
+
height: 1.5rem;
|
|
105
|
+
flex: none;
|
|
106
|
+
}
|
|
107
|
+
.everywhere-fab-label {
|
|
108
|
+
white-space: nowrap;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/* In-content action sheet (everywhere_menu) inline fallback: the items stay
|
|
112
|
+
* hidden until the trigger opens them. In the native shell the bridge shows a
|
|
113
|
+
* real action sheet instead and these never appear. */
|
|
114
|
+
[data-everywhere-menu-items] {
|
|
115
|
+
display: none;
|
|
116
|
+
}
|
|
117
|
+
[data-everywhere-menu-open] > [data-everywhere-menu-items],
|
|
118
|
+
[data-everywhere-menu-open] [data-everywhere-menu-items] {
|
|
119
|
+
display: block;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/* Nav-bar sources (everywhere_nav_button / everywhere_nav_menu) live in the
|
|
123
|
+
* top bar inside the shell, so hide the in-page copy there — but keep it in a
|
|
124
|
+
* browser, where there is no native bar. Keyed on the bridge's platform marker
|
|
125
|
+
* (set only by the mobile web bridge). */
|
|
126
|
+
html[data-bridge-platform] [data-everywhere-nav-button],
|
|
127
|
+
html[data-bridge-platform] [data-everywhere-nav-menu] {
|
|
128
|
+
display: none !important;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/* DOM action-sheet fallback rendered by the bridge (Everywhere.menu / the
|
|
132
|
+
* everywhere_menu trigger) when there is no native shell. */
|
|
133
|
+
.everywhere-sheet-overlay {
|
|
134
|
+
position: fixed;
|
|
135
|
+
inset: 0;
|
|
136
|
+
z-index: 2000;
|
|
137
|
+
display: flex;
|
|
138
|
+
align-items: flex-end;
|
|
139
|
+
justify-content: center;
|
|
140
|
+
padding: 0.75rem;
|
|
141
|
+
padding-bottom: calc(var(--everywhere-inset-bottom) + 0.75rem);
|
|
142
|
+
background: rgba(0, 0, 0, 0.4);
|
|
143
|
+
animation: everywhere-sheet-fade 0.15s ease;
|
|
144
|
+
}
|
|
145
|
+
.everywhere-sheet {
|
|
146
|
+
width: 100%;
|
|
147
|
+
max-width: 30rem;
|
|
148
|
+
display: flex;
|
|
149
|
+
flex-direction: column;
|
|
150
|
+
gap: 1px;
|
|
151
|
+
border-radius: 0.9rem;
|
|
152
|
+
overflow: hidden;
|
|
153
|
+
background: rgba(120, 120, 128, 0.2);
|
|
154
|
+
animation: everywhere-sheet-rise 0.2s cubic-bezier(0.2, 0.8, 0.2, 1);
|
|
155
|
+
}
|
|
156
|
+
.everywhere-sheet-header {
|
|
157
|
+
padding: 0.85rem 1rem;
|
|
158
|
+
text-align: center;
|
|
159
|
+
background: Canvas;
|
|
160
|
+
color: color-mix(in srgb, CanvasText 60%, transparent);
|
|
161
|
+
}
|
|
162
|
+
.everywhere-sheet-title {
|
|
163
|
+
font-weight: 600;
|
|
164
|
+
color: CanvasText;
|
|
165
|
+
}
|
|
166
|
+
.everywhere-sheet-message {
|
|
167
|
+
font-size: 0.85rem;
|
|
168
|
+
margin-top: 0.15rem;
|
|
169
|
+
}
|
|
170
|
+
.everywhere-sheet-item {
|
|
171
|
+
appearance: none;
|
|
172
|
+
border: 0;
|
|
173
|
+
width: 100%;
|
|
174
|
+
padding: 1rem;
|
|
175
|
+
font: inherit;
|
|
176
|
+
font-size: 1.05rem;
|
|
177
|
+
text-align: center;
|
|
178
|
+
background: Canvas;
|
|
179
|
+
color: var(--everywhere-tint, #1a73e8);
|
|
180
|
+
cursor: pointer;
|
|
181
|
+
}
|
|
182
|
+
.everywhere-sheet-item:active {
|
|
183
|
+
background: color-mix(in srgb, CanvasText 8%, Canvas);
|
|
184
|
+
}
|
|
185
|
+
.everywhere-sheet-item:disabled {
|
|
186
|
+
color: color-mix(in srgb, CanvasText 30%, transparent);
|
|
187
|
+
cursor: default;
|
|
188
|
+
}
|
|
189
|
+
.everywhere-sheet-item-destructive {
|
|
190
|
+
color: #e5484d;
|
|
191
|
+
}
|
|
192
|
+
.everywhere-sheet-cancel {
|
|
193
|
+
margin-top: 0.5rem;
|
|
194
|
+
border-radius: 0.9rem;
|
|
195
|
+
font-weight: 600;
|
|
196
|
+
color: CanvasText;
|
|
197
|
+
}
|
|
198
|
+
@keyframes everywhere-sheet-fade {
|
|
199
|
+
from { opacity: 0; }
|
|
200
|
+
}
|
|
201
|
+
@keyframes everywhere-sheet-rise {
|
|
202
|
+
from { transform: translateY(1rem); opacity: 0; }
|
|
203
|
+
}
|
data/bridge/package.json
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rubyeverywhere/bridge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "One API for browser, desktop, and mobile — the JS half of RubyEverywhere",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "everywhere/bridge.js",
|
|
7
|
-
"exports": {
|
|
8
|
-
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./everywhere/bridge.js",
|
|
9
|
+
"./native.css": "./everywhere/native.css"
|
|
10
|
+
},
|
|
11
|
+
"files": ["everywhere/bridge.js", "everywhere/native.css", "README.md", "LICENSE"],
|
|
9
12
|
"sideEffects": false,
|
|
10
13
|
"keywords": ["rubyeverywhere", "tauri", "hotwire", "turbo", "rails", "desktop", "native", "bridge"],
|
|
11
14
|
"homepage": "https://rubyeverywhere.com",
|
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require_relative "../paths"
|
|
5
|
+
require_relative "../shellout"
|
|
6
|
+
require_relative "../log_filter"
|
|
7
|
+
require_relative "../icon"
|
|
8
|
+
require_relative "../ui"
|
|
9
|
+
|
|
10
|
+
module Everywhere
|
|
11
|
+
module Builders
|
|
12
|
+
# Assembles a Hotwire Native iOS app around an already-deployed (remote
|
|
13
|
+
# mode) app. No Tebako on iOS — the "build" is: copy the gem's frozen
|
|
14
|
+
# Xcode template, stamp it (xcconfig + everywhere.json + AppIcon; never
|
|
15
|
+
# the pbxproj), and hand it to xcodebuild.
|
|
16
|
+
#
|
|
17
|
+
# MVP scope is the Simulator (ad-hoc signed, no certs). Device signing,
|
|
18
|
+
# archive/.ipa export and store distribution hang off this same seam
|
|
19
|
+
# later — see platform/docs/build-engine.md §5.
|
|
20
|
+
class Ios
|
|
21
|
+
# Fixed by the template contract: the target/product is always "App"
|
|
22
|
+
# (App.app), so build paths never depend on per-app values.
|
|
23
|
+
SCHEME = "App"
|
|
24
|
+
|
|
25
|
+
def initialize(config:, root:, target:, template_dir: nil)
|
|
26
|
+
@config = config
|
|
27
|
+
@root = root
|
|
28
|
+
@target = target
|
|
29
|
+
@template_dir = template_dir
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Build the Simulator .app and copy it into dist_dir. `dev` builds Debug
|
|
33
|
+
# and relaxes the remote-mode requirement (the dev URL arrives at launch
|
|
34
|
+
# via SIMCTL_CHILD_EVERYWHERE_DEV_URL, not baked into the bundle).
|
|
35
|
+
# Returns the path of the built .app.
|
|
36
|
+
def build!(dist_dir:, dev: false)
|
|
37
|
+
preflight!(dev: dev)
|
|
38
|
+
template = @template_dir ? File.expand_path(@template_dir) : Paths.ios_dir!
|
|
39
|
+
work = stage(template)
|
|
40
|
+
stamp!(work)
|
|
41
|
+
configuration = dev ? "Debug" : "Release"
|
|
42
|
+
xcodebuild!(work, configuration, dist_dir)
|
|
43
|
+
collect(configuration, dist_dir)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def name = @config.name(target: @target)
|
|
49
|
+
def bundle_id = @config.bundle_id(target: @target)
|
|
50
|
+
|
|
51
|
+
def preflight!(dev:)
|
|
52
|
+
unless dev || @config.remote?
|
|
53
|
+
UI.die!("iOS apps are remote mode only for now — set remote.url in config/everywhere.yml " \
|
|
54
|
+
"(the shell wraps your deployed app; nothing is packaged locally)")
|
|
55
|
+
end
|
|
56
|
+
UI.die!("iOS builds need macOS with Xcode installed") unless RUBY_PLATFORM.include?("darwin")
|
|
57
|
+
return if Shellout.run?("xcrun", "--sdk", "iphonesimulator", "--show-sdk-path", quiet: true)
|
|
58
|
+
|
|
59
|
+
UI.die!("no iOS Simulator SDK — install full Xcode (not just the Command Line Tools) " \
|
|
60
|
+
"and run: xcode-select --switch /Applications/Xcode.app")
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Copy the template into the app's persistent work dir. Persistent (not
|
|
64
|
+
# a tmpdir) so the project path is stable — Xcode's incremental state
|
|
65
|
+
# stays valid and users can open the stamped project directly.
|
|
66
|
+
def stage(template)
|
|
67
|
+
work = Paths.ios_work_dir(bundle_id)
|
|
68
|
+
FileUtils.mkdir_p(File.dirname(work))
|
|
69
|
+
FileUtils.rm_rf(work)
|
|
70
|
+
FileUtils.cp_r(template, work)
|
|
71
|
+
work
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# The whole per-app surface: one xcconfig (identity), two JSON resources
|
|
75
|
+
# (runtime config + path configuration with baked tabs), one PNG (icon),
|
|
76
|
+
# and the Extensions/ buildable folder (app-authored Swift + generated
|
|
77
|
+
# registry). The pbxproj is never touched.
|
|
78
|
+
def stamp!(work)
|
|
79
|
+
UI.step("stamping shell #{UI.dim("(#{bundle_id} → #{UI.short_path(work)})")}")
|
|
80
|
+
write_xcconfig(work)
|
|
81
|
+
File.write(File.join(work, "App", "Resources", "everywhere.json"),
|
|
82
|
+
@config.to_shell_json(target: @target))
|
|
83
|
+
# Same document MobileConfigEndpoint serves live — this baked copy is
|
|
84
|
+
# the offline/first-launch fallback the shell loads synchronously.
|
|
85
|
+
File.write(File.join(work, "App", "Resources", "path-configuration.json"),
|
|
86
|
+
@config.path_configuration_json("ios"))
|
|
87
|
+
write_launch_background(work)
|
|
88
|
+
write_usage_strings(work)
|
|
89
|
+
write_entitlements(work)
|
|
90
|
+
write_app_icon(work)
|
|
91
|
+
write_extensions(work)
|
|
92
|
+
write_native_packages(work)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Associated Domains entitlement for deep linking (everywhere.yml
|
|
96
|
+
# deep_linking:). Written only when configured — the default build stays
|
|
97
|
+
# entitlement-free, so the frozen template still compiles unchanged. The
|
|
98
|
+
# matching CODE_SIGN_ENTITLEMENTS is set in the xcconfig (write_xcconfig).
|
|
99
|
+
# applinks: for universal links, webcredentials: to match the association
|
|
100
|
+
# file's webcredentials section (password autofill / associated credentials).
|
|
101
|
+
def write_entitlements(work)
|
|
102
|
+
domains = @config.deep_linking_domains
|
|
103
|
+
return unless @config.deep_linking? && domains.any?
|
|
104
|
+
|
|
105
|
+
values = domains.flat_map { |host| ["applinks:#{host}", "webcredentials:#{host}"] }
|
|
106
|
+
entries = values.map { |v| "\t\t<string>#{v}</string>" }.join("\n")
|
|
107
|
+
UI.step("stamping associated domains #{UI.dim("(#{domains.join(", ")})")}")
|
|
108
|
+
File.write(File.join(work, "App", "App.entitlements"), <<~PLIST)
|
|
109
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
110
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
111
|
+
<plist version="1.0">
|
|
112
|
+
<dict>
|
|
113
|
+
\t<key>com.apple.developer.associated-domains</key>
|
|
114
|
+
\t<array>
|
|
115
|
+
#{entries}
|
|
116
|
+
\t</array>
|
|
117
|
+
</dict>
|
|
118
|
+
</plist>
|
|
119
|
+
PLIST
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Native extensions: the app repo's native/ios/*.swift copied into the
|
|
123
|
+
# template's Extensions/ folder, plus a generated registry naming what
|
|
124
|
+
# everywhere.yml declares (native.ios). Extensions/ is a filesystem-
|
|
125
|
+
# synchronized (buildable) group, so dropping files in IS the
|
|
126
|
+
# integration — the stamp-only contract holds.
|
|
127
|
+
def write_extensions(work)
|
|
128
|
+
errors = @config.native_ios_errors
|
|
129
|
+
UI.die!("native.ios in config/everywhere.yml:\n#{errors.join("\n")}") unless errors.empty?
|
|
130
|
+
|
|
131
|
+
dest = File.join(work, "App", "Extensions")
|
|
132
|
+
source = File.join(@root, "native", "ios")
|
|
133
|
+
sources = File.directory?(source) ? Dir.glob(File.join(source, "**", "*.swift")).sort : []
|
|
134
|
+
|
|
135
|
+
sources.each do |file|
|
|
136
|
+
relative = file.delete_prefix("#{source}/")
|
|
137
|
+
if File.basename(file) == "EverywhereExtensions.swift"
|
|
138
|
+
UI.die!("native/ios/#{relative}: EverywhereExtensions.swift is generated by " \
|
|
139
|
+
"`every build` — declare your types under native.ios in config/everywhere.yml instead")
|
|
140
|
+
end
|
|
141
|
+
target_file = File.join(dest, relative)
|
|
142
|
+
FileUtils.mkdir_p(File.dirname(target_file))
|
|
143
|
+
FileUtils.cp(file, target_file)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
if @config.native_ios?
|
|
147
|
+
UI.step("bundling native extensions #{UI.dim("(#{sources.length} Swift files from native/ios)")}")
|
|
148
|
+
FileUtils.mkdir_p(dest)
|
|
149
|
+
File.write(File.join(dest, "EverywhereExtensions.swift"), extensions_registry)
|
|
150
|
+
elsif sources.any?
|
|
151
|
+
UI.warn "native/ios has Swift files but everywhere.yml declares nothing under native.ios — " \
|
|
152
|
+
"they compile in, but no components/screens/splash are hooked up"
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# The generated half of the extension seam (the template ships an empty
|
|
157
|
+
# default). Declared names are validated as Swift identifiers above, so
|
|
158
|
+
# interpolation is safe; a declared-but-missing type fails the build
|
|
159
|
+
# with a Swift error naming it — the honest signal.
|
|
160
|
+
def extensions_registry
|
|
161
|
+
components = @config.native_ios_components.map { |type| "#{type}.self" }.join(", ")
|
|
162
|
+
screens = @config.native_ios_screens
|
|
163
|
+
splash = @config.native_ios_splash
|
|
164
|
+
|
|
165
|
+
screen_body =
|
|
166
|
+
if screens.empty?
|
|
167
|
+
" nil"
|
|
168
|
+
else
|
|
169
|
+
[" switch identifier {",
|
|
170
|
+
*screens.map { |id, type| " case #{id.inspect}: return everywhereHost(#{type}(url: url))" },
|
|
171
|
+
" default: return nil",
|
|
172
|
+
" }"].join("\n")
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
<<~SWIFT
|
|
176
|
+
// Written by `every build` from config/everywhere.yml (native.ios) — do
|
|
177
|
+
// not edit; changes belong in native/ios/ and everywhere.yml.
|
|
178
|
+
import HotwireNative
|
|
179
|
+
import SwiftUI
|
|
180
|
+
import UIKit
|
|
181
|
+
|
|
182
|
+
enum EverywhereExtensions {
|
|
183
|
+
static let components: [BridgeComponent.Type] = [#{components}]
|
|
184
|
+
|
|
185
|
+
static func screen(for identifier: String, url: URL) -> UIViewController? {
|
|
186
|
+
#{screen_body}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
static func splashViewController() -> UIViewController? {
|
|
190
|
+
#{splash ? "everywhereHost(#{splash}())" : "nil"}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
SWIFT
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Third-party Swift packages → the template's local NativeExtensions
|
|
197
|
+
# package. The App target already links (a fixed, frozen pbxproj entry)
|
|
198
|
+
# this local package; we only regenerate its manifest and the re-export
|
|
199
|
+
# shim, so app-authored native/ios code reaches every pinned product with
|
|
200
|
+
# `import NativeExtensions` — and the stamp-only contract holds (the
|
|
201
|
+
# pbxproj is never touched, packages flow through Package.swift instead).
|
|
202
|
+
def write_native_packages(work)
|
|
203
|
+
packages = @config.native_ios_packages
|
|
204
|
+
pkg_dir = File.join(work, "NativeExtensions")
|
|
205
|
+
sources = File.join(pkg_dir, "Sources", "NativeExtensions")
|
|
206
|
+
FileUtils.mkdir_p(sources)
|
|
207
|
+
# A stray Xcode/SPM index cache in the template must not leak into the
|
|
208
|
+
# app's build (it pins a different dependency graph).
|
|
209
|
+
FileUtils.rm_rf(File.join(pkg_dir, ".build"))
|
|
210
|
+
|
|
211
|
+
File.write(File.join(pkg_dir, "Package.swift"), native_package_manifest(packages))
|
|
212
|
+
File.write(File.join(sources, "Exports.swift"), native_package_exports(packages))
|
|
213
|
+
|
|
214
|
+
return if packages.empty?
|
|
215
|
+
|
|
216
|
+
UI.step("pinning native packages #{UI.dim("(#{packages.length} SPM " \
|
|
217
|
+
"dependenc#{packages.length == 1 ? "y" : "ies"} in NativeExtensions)")}")
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# The generated NativeExtensions/Package.swift. Every string interpolated
|
|
221
|
+
# here is validated by Config#native_ios_package_errors (URLs, semver,
|
|
222
|
+
# module names), and .inspect renders Swift-compatible string literals.
|
|
223
|
+
def native_package_manifest(packages)
|
|
224
|
+
dependencies = packages.map do |pkg|
|
|
225
|
+
".package(url: #{pkg["url"].inspect}, #{swift_requirement(pkg["requirement"])})"
|
|
226
|
+
end
|
|
227
|
+
products = packages.flat_map do |pkg|
|
|
228
|
+
identity = @config.package_identity(pkg["url"])
|
|
229
|
+
pkg["products"].map { |name| ".product(name: #{name.inspect}, package: #{identity.inspect})" }
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
<<~SWIFT
|
|
233
|
+
// swift-tools-version:5.9
|
|
234
|
+
// Written by `every build` from config/everywhere.yml (native.ios.packages)
|
|
235
|
+
// — do not edit; changes belong in config/everywhere.yml.
|
|
236
|
+
import PackageDescription
|
|
237
|
+
|
|
238
|
+
let package = Package(
|
|
239
|
+
name: "NativeExtensions",
|
|
240
|
+
platforms: [.iOS(.v15)],
|
|
241
|
+
products: [
|
|
242
|
+
.library(name: "NativeExtensions", targets: ["NativeExtensions"])
|
|
243
|
+
],
|
|
244
|
+
dependencies: #{swift_array(dependencies, indent: 8)},
|
|
245
|
+
targets: [
|
|
246
|
+
.target(name: "NativeExtensions", dependencies: #{swift_array(products, indent: 12)})
|
|
247
|
+
]
|
|
248
|
+
)
|
|
249
|
+
SWIFT
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# The re-export shim: one `@_exported import` per pinned product, so the
|
|
253
|
+
# App target's native/ios code sees every module through NativeExtensions.
|
|
254
|
+
def native_package_exports(packages)
|
|
255
|
+
header = <<~SWIFT
|
|
256
|
+
// Written by `every build` from config/everywhere.yml (native.ios.packages)
|
|
257
|
+
// — re-exports each pinned product so native/ios code reaches it with a
|
|
258
|
+
// single `import NativeExtensions`. Do not edit.
|
|
259
|
+
SWIFT
|
|
260
|
+
modules = packages.flat_map { |pkg| pkg["products"] }.uniq.sort
|
|
261
|
+
return header if modules.empty?
|
|
262
|
+
|
|
263
|
+
"#{header}#{modules.map { |m| "@_exported import #{m}" }.join("\n")}\n"
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def swift_requirement(requirement)
|
|
267
|
+
value = requirement["value"].inspect
|
|
268
|
+
"#{requirement["kind"]}: #{value}"
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# A Swift array literal: "[]" when empty, else one element per line at
|
|
272
|
+
# `indent` spaces with the closing bracket de-dented by four.
|
|
273
|
+
def swift_array(elements, indent:)
|
|
274
|
+
return "[]" if elements.empty?
|
|
275
|
+
|
|
276
|
+
pad = " " * indent
|
|
277
|
+
"[\n#{elements.map { |element| "#{pad}#{element}" }.join(",\n")}\n#{" " * (indent - 4)}]"
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# Permission usage strings → Info.plist. iOS crashes a permission
|
|
281
|
+
# request whose usage-description key is missing, so declarations are
|
|
282
|
+
# validated here (good strings are mandatory) and stamped with plutil.
|
|
283
|
+
def write_usage_strings(work)
|
|
284
|
+
errors = @config.permission_errors("ios")
|
|
285
|
+
UI.die!("permissions in config/everywhere.yml:\n#{errors.join("\n")}") unless errors.empty?
|
|
286
|
+
|
|
287
|
+
plist = File.join(work, "App", "Info.plist")
|
|
288
|
+
@config.permissions.each do |name, usage|
|
|
289
|
+
key = Everywhere::Config::IOS_USAGE_KEYS[name] or next
|
|
290
|
+
Shellout.run!({}, "plutil", "-replace", key, "-string", usage["ios"], plist)
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# The launch-screen (and splash overlay) background — the storyboard's
|
|
295
|
+
# LaunchBackground named color, stamped from appearance.background_color
|
|
296
|
+
# so the launch frame matches the app instead of the template default.
|
|
297
|
+
def write_launch_background(work)
|
|
298
|
+
colors = @config.background_color
|
|
299
|
+
light = Icon.hex_rgb(colors&.fetch("light", nil)) or return
|
|
300
|
+
dark = Icon.hex_rgb(colors["dark"]) || light
|
|
301
|
+
|
|
302
|
+
entry = lambda do |rgb, appearances|
|
|
303
|
+
{ "color" => {
|
|
304
|
+
"color-space" => "srgb",
|
|
305
|
+
"components" => {
|
|
306
|
+
"alpha" => "1.000",
|
|
307
|
+
"red" => format("0x%02X", rgb[0]),
|
|
308
|
+
"green" => format("0x%02X", rgb[1]),
|
|
309
|
+
"blue" => format("0x%02X", rgb[2])
|
|
310
|
+
}
|
|
311
|
+
},
|
|
312
|
+
"idiom" => "universal" }.merge(appearances)
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
require "json"
|
|
316
|
+
dest = File.join(work, "App", "Assets.xcassets", "LaunchBackground.colorset")
|
|
317
|
+
FileUtils.mkdir_p(dest)
|
|
318
|
+
File.write(
|
|
319
|
+
File.join(dest, "Contents.json"),
|
|
320
|
+
JSON.pretty_generate(
|
|
321
|
+
"colors" => [
|
|
322
|
+
entry.call(light, {}),
|
|
323
|
+
entry.call(dark, "appearances" => [{ "appearance" => "luminosity", "value" => "dark" }])
|
|
324
|
+
],
|
|
325
|
+
"info" => { "author" => "xcode", "version" => 1 }
|
|
326
|
+
)
|
|
327
|
+
)
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def write_xcconfig(work)
|
|
331
|
+
# Only point at an entitlements file when deep linking wrote one — an
|
|
332
|
+
# empty CODE_SIGN_ENTITLEMENTS would break the signing step.
|
|
333
|
+
entitlements = "CODE_SIGN_ENTITLEMENTS = App/App.entitlements\n" if @config.deep_linking? && @config.deep_linking_domains.any?
|
|
334
|
+
File.write(File.join(work, "App", "App.xcconfig"), <<~XCCONFIG)
|
|
335
|
+
// Written by `every build --target #{@target}` — do not edit; changes
|
|
336
|
+
// belong in config/everywhere.yml.
|
|
337
|
+
PRODUCT_BUNDLE_IDENTIFIER = #{bundle_id}
|
|
338
|
+
MARKETING_VERSION = #{@config.version(target: @target)}
|
|
339
|
+
CURRENT_PROJECT_VERSION = 1
|
|
340
|
+
INFOPLIST_KEY_CFBundleDisplayName = #{name}
|
|
341
|
+
DEVELOPMENT_TEAM =
|
|
342
|
+
#{entitlements}
|
|
343
|
+
XCCONFIG
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def write_app_icon(work)
|
|
347
|
+
source = @config.icon
|
|
348
|
+
unless source && File.exist?(source)
|
|
349
|
+
UI.warn "no app icon (set app.icon or add icon.png) — shipping the placeholder"
|
|
350
|
+
return
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
background = Icon.hex_rgb(@config.background_color&.fetch("light", nil))
|
|
354
|
+
dest = File.join(work, "App", "Assets.xcassets", "AppIcon.appiconset")
|
|
355
|
+
return if Icon.write_ios_appiconset(source, dest, background: background)
|
|
356
|
+
|
|
357
|
+
UI.warn "couldn't read #{File.basename(source)} — shipping the placeholder icon"
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
def xcodebuild!(work, configuration, dist_dir)
|
|
361
|
+
UI.step("building iOS shell #{UI.dim("(xcodebuild, #{configuration}, first run compiles HotwireNative)")}")
|
|
362
|
+
FileUtils.mkdir_p(dist_dir)
|
|
363
|
+
log = File.join(dist_dir, "ios-build.log")
|
|
364
|
+
Shellout.run_logged!({},
|
|
365
|
+
["xcodebuild", "build",
|
|
366
|
+
"-project", File.join(work, "App.xcodeproj"),
|
|
367
|
+
"-scheme", SCHEME,
|
|
368
|
+
"-configuration", configuration,
|
|
369
|
+
"-destination", "generic/platform=iOS Simulator",
|
|
370
|
+
"-derivedDataPath", Paths.ios_derived_data_dir,
|
|
371
|
+
"-clonedSourcePackagesDirPath", Paths.ios_packages_dir,
|
|
372
|
+
# Ad-hoc sign (no certs needed on the Simulator).
|
|
373
|
+
# CODE_SIGNING_ALLOWED=NO ships an UNSIGNED app,
|
|
374
|
+
# and securityd rejects its data-protection
|
|
375
|
+
# keychain writes (SecAccessControl items, e.g.
|
|
376
|
+
# the Face ID login credential) with -34018.
|
|
377
|
+
"CODE_SIGN_IDENTITY=-"],
|
|
378
|
+
log: log, filter: LogFilter.for(:xcodebuild))
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
# Copy the product out of DerivedData. The ".simulator.app" suffix keeps
|
|
382
|
+
# it visibly distinct from the desktop dist/<Name>.app (and out of
|
|
383
|
+
# release.rb's dist/*.app glob).
|
|
384
|
+
def collect(configuration, dist_dir)
|
|
385
|
+
built = File.join(Paths.ios_derived_data_dir, "Build", "Products",
|
|
386
|
+
"#{configuration}-iphonesimulator", "App.app")
|
|
387
|
+
UI.die!("xcodebuild reported success but #{built} is missing") unless File.directory?(built)
|
|
388
|
+
|
|
389
|
+
dest = File.join(dist_dir, "#{name}.simulator.app")
|
|
390
|
+
FileUtils.rm_rf(dest)
|
|
391
|
+
FileUtils.cp_r(built, dest)
|
|
392
|
+
dest
|
|
393
|
+
end
|
|
394
|
+
end
|
|
395
|
+
end
|
|
396
|
+
end
|
data/lib/everywhere/cli.rb
CHANGED
|
@@ -6,6 +6,7 @@ require_relative "framework"
|
|
|
6
6
|
require_relative "commands/build"
|
|
7
7
|
require_relative "commands/clean"
|
|
8
8
|
require_relative "commands/dev"
|
|
9
|
+
require_relative "commands/logs"
|
|
9
10
|
require_relative "commands/icon"
|
|
10
11
|
require_relative "commands/doctor"
|
|
11
12
|
require_relative "commands/install"
|
|
@@ -37,6 +38,7 @@ module Everywhere
|
|
|
37
38
|
register "doctor", Commands::Doctor
|
|
38
39
|
register "install", Commands::Install
|
|
39
40
|
register "dev", Commands::Dev
|
|
41
|
+
register "logs", Commands::Logs
|
|
40
42
|
register "build", Commands::Build
|
|
41
43
|
register "clean", Commands::Clean
|
|
42
44
|
register "icon", Commands::Icon
|
|
@@ -9,6 +9,7 @@ require_relative "../log_filter"
|
|
|
9
9
|
require_relative "../png"
|
|
10
10
|
require_relative "../icon"
|
|
11
11
|
require_relative "../paths"
|
|
12
|
+
require_relative "../builders/ios"
|
|
12
13
|
|
|
13
14
|
module Everywhere
|
|
14
15
|
module Commands
|
|
@@ -23,7 +24,7 @@ module Everywhere
|
|
|
23
24
|
class Build < Dry::CLI::Command
|
|
24
25
|
SHIM = File.expand_path("~/.tebako-shims/clang++")
|
|
25
26
|
|
|
26
|
-
desc "Package the app
|
|
27
|
+
desc "Package the app natively — desktop (tebako press + shell + .app) or iOS (--target ios-arm64)"
|
|
27
28
|
|
|
28
29
|
option :root, default: ".", desc: "App root directory"
|
|
29
30
|
option :output, default: nil, desc: "Output binary path (default: dist/<app>)"
|
|
@@ -38,6 +39,21 @@ module Everywhere
|
|
|
38
39
|
app_name: nil, target: "macos-arm64", shell_dir: nil, skip_press: false, **)
|
|
39
40
|
root_path = File.expand_path(root)
|
|
40
41
|
config = Everywhere::Config.load(root_path)
|
|
42
|
+
|
|
43
|
+
# Builder dispatch on the target's os (build-engine.md §5): macOS is
|
|
44
|
+
# the tebako+tauri path below; iOS is a Hotwire Native shell built by
|
|
45
|
+
# its own builder. Anything else has no builder yet.
|
|
46
|
+
case Everywhere::Config.os_of(target)
|
|
47
|
+
when "ios"
|
|
48
|
+
app = Builders::Ios.new(config: config, root: root_path, target: target)
|
|
49
|
+
.build!(dist_dir: File.expand_path("dist", root_path))
|
|
50
|
+
UI.success("built #{rel(app, root_path)}")
|
|
51
|
+
return
|
|
52
|
+
when "macos" # the rest of this method
|
|
53
|
+
else
|
|
54
|
+
UI.die!("no builder for target #{target} — macos-* and ios-* are supported")
|
|
55
|
+
end
|
|
56
|
+
|
|
41
57
|
# The os of `target` selects any per-platform overrides (bundle id,
|
|
42
58
|
# version, name) — local builds always target the host, macOS today.
|
|
43
59
|
app_name ||= config.name(target: target)
|