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.
Files changed (61) hide show
  1. checksums.yaml +4 -4
  2. data/bridge/README.md +105 -2
  3. data/bridge/everywhere/bridge.js +1131 -9
  4. data/bridge/everywhere/native.css +203 -0
  5. data/bridge/package.json +6 -3
  6. data/lib/everywhere/builders/ios.rb +396 -0
  7. data/lib/everywhere/cli.rb +2 -0
  8. data/lib/everywhere/commands/build.rb +17 -1
  9. data/lib/everywhere/commands/clean.rb +19 -10
  10. data/lib/everywhere/commands/dev.rb +182 -19
  11. data/lib/everywhere/commands/doctor.rb +45 -3
  12. data/lib/everywhere/commands/icon.rb +14 -0
  13. data/lib/everywhere/commands/install.rb +37 -6
  14. data/lib/everywhere/commands/logs.rb +56 -0
  15. data/lib/everywhere/commands/platform/build.rb +3 -3
  16. data/lib/everywhere/commands/platform/runner.rb +1 -1
  17. data/lib/everywhere/commands/release.rb +1 -1
  18. data/lib/everywhere/commands/shell_dir.rb +11 -4
  19. data/lib/everywhere/config.rb +469 -1
  20. data/lib/everywhere/engine.rb +42 -1
  21. data/lib/everywhere/icon.rb +50 -0
  22. data/lib/everywhere/log_filter.rb +28 -2
  23. data/lib/everywhere/mobile_config_endpoint.rb +88 -0
  24. data/lib/everywhere/mobile_configs_controller.rb +64 -0
  25. data/lib/everywhere/native_helper.rb +352 -0
  26. data/lib/everywhere/paths.rb +41 -0
  27. data/lib/everywhere/raster.rb +17 -0
  28. data/lib/everywhere/shellout.rb +3 -1
  29. data/lib/everywhere/simulator.rb +74 -0
  30. data/lib/everywhere/ui.rb +18 -0
  31. data/lib/everywhere/version.rb +1 -1
  32. data/support/mobile/ios/App/App.xcconfig +6 -0
  33. data/support/mobile/ios/App/AppDelegate.swift +164 -0
  34. data/support/mobile/ios/App/Assets.xcassets/AccentColor.colorset/Contents.json +20 -0
  35. data/support/mobile/ios/App/Assets.xcassets/AppIcon.appiconset/AppIcon.png +0 -0
  36. data/support/mobile/ios/App/Assets.xcassets/AppIcon.appiconset/Contents.json +14 -0
  37. data/support/mobile/ios/App/Assets.xcassets/Contents.json +6 -0
  38. data/support/mobile/ios/App/Assets.xcassets/LaunchBackground.colorset/Contents.json +38 -0
  39. data/support/mobile/ios/App/Base.lproj/LaunchScreen.storyboard +32 -0
  40. data/support/mobile/ios/App/Bridge/BiometricsComponent.swift +276 -0
  41. data/support/mobile/ios/App/Bridge/HapticsComponent.swift +47 -0
  42. data/support/mobile/ios/App/Bridge/MenuComponent.swift +192 -0
  43. data/support/mobile/ios/App/Bridge/NotificationComponent.swift +56 -0
  44. data/support/mobile/ios/App/Bridge/PermissionsComponent.swift +142 -0
  45. data/support/mobile/ios/App/Bridge/StorageComponent.swift +63 -0
  46. data/support/mobile/ios/App/ErrorViewController.swift +64 -0
  47. data/support/mobile/ios/App/EverywhereConfig.swift +289 -0
  48. data/support/mobile/ios/App/EverywhereHost.swift +34 -0
  49. data/support/mobile/ios/App/Extensions/EverywhereExtensions.swift +32 -0
  50. data/support/mobile/ios/App/Info.plist +28 -0
  51. data/support/mobile/ios/App/Resources/everywhere.json +8 -0
  52. data/support/mobile/ios/App/Resources/path-configuration.json +19 -0
  53. data/support/mobile/ios/App/SceneDelegate.swift +484 -0
  54. data/support/mobile/ios/App.xcodeproj/project.pbxproj +458 -0
  55. data/support/mobile/ios/App.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
  56. data/support/mobile/ios/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +14 -0
  57. data/support/mobile/ios/App.xcodeproj/xcshareddata/xcschemes/App.xcscheme +77 -0
  58. data/support/mobile/ios/NativeExtensions/Package.swift +26 -0
  59. data/support/mobile/ios/NativeExtensions/Sources/NativeExtensions/Exports.swift +5 -0
  60. data/support/mobile/ios/README.md +73 -0
  61. 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.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": { ".": "./everywhere/bridge.js" },
8
- "files": ["everywhere/bridge.js", "README.md", "LICENSE"],
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
@@ -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 into a double-clickable desktop app (tebako press + shell + .app bundle)"
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)