ruby_everywhere 0.3.0 → 0.4.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 (88) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +255 -0
  3. data/bridge/everywhere/bridge.js +60 -3
  4. data/lib/everywhere/android_resources.rb +217 -0
  5. data/lib/everywhere/android_sdk.rb +330 -0
  6. data/lib/everywhere/asset_catalog.rb +185 -0
  7. data/lib/everywhere/auth_handoff.rb +398 -0
  8. data/lib/everywhere/auth_token.rb +118 -0
  9. data/lib/everywhere/builders/android.rb +844 -0
  10. data/lib/everywhere/builders/ios.rb +40 -3
  11. data/lib/everywhere/commands/build.rb +51 -9
  12. data/lib/everywhere/commands/clean.rb +10 -5
  13. data/lib/everywhere/commands/dev.rb +46 -8
  14. data/lib/everywhere/commands/doctor.rb +89 -3
  15. data/lib/everywhere/commands/install.rb +1 -1
  16. data/lib/everywhere/commands/logs.rb +117 -8
  17. data/lib/everywhere/config.rb +409 -13
  18. data/lib/everywhere/emulator.rb +405 -0
  19. data/lib/everywhere/engine.rb +12 -0
  20. data/lib/everywhere/icon.rb +196 -0
  21. data/lib/everywhere/log_filter.rb +74 -0
  22. data/lib/everywhere/native_helper.rb +2 -5
  23. data/lib/everywhere/omniauth.rb +88 -0
  24. data/lib/everywhere/paths.rb +47 -0
  25. data/lib/everywhere/version.rb +1 -1
  26. data/support/mobile/android/README.md +217 -0
  27. data/support/mobile/android/app/build.gradle.kts +156 -0
  28. data/support/mobile/android/app/everywhere.properties +8 -0
  29. data/support/mobile/android/app/native-packages.gradle.kts +16 -0
  30. data/support/mobile/android/app/proguard-rules.pro +21 -0
  31. data/support/mobile/android/app/src/debug/res/xml/network_security_config.xml +30 -0
  32. data/support/mobile/android/app/src/main/AndroidManifest.xml +42 -0
  33. data/support/mobile/android/app/src/main/assets/everywhere.json +9 -0
  34. data/support/mobile/android/app/src/main/assets/fonts/MaterialIcons-Regular.codepoints +2235 -0
  35. data/support/mobile/android/app/src/main/assets/fonts/MaterialIcons-Regular.ttf +0 -0
  36. data/support/mobile/android/app/src/main/assets/json/path-configuration.json +19 -0
  37. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/AuthFlow.kt +268 -0
  38. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereApplication.kt +178 -0
  39. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereConfig.kt +409 -0
  40. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereEvents.kt +68 -0
  41. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereHost.kt +101 -0
  42. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/IconFont.kt +211 -0
  43. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/MainActivity.kt +975 -0
  44. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/MainTabs.kt +201 -0
  45. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/WebBottomSheetFragment.kt +13 -0
  46. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/WebFragment.kt +36 -0
  47. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/BiometricsComponent.kt +535 -0
  48. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/HapticsComponent.kt +141 -0
  49. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/MenuComponent.kt +361 -0
  50. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/NotificationComponent.kt +151 -0
  51. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/PermissionsComponent.kt +271 -0
  52. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/StorageComponent.kt +101 -0
  53. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/WebControlChannel.kt +204 -0
  54. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/extensions/EverywhereExtensions.kt +51 -0
  55. data/support/mobile/android/app/src/main/res/color/bottom_navigation_active_indicator.xml +4 -0
  56. data/support/mobile/android/app/src/main/res/color/bottom_navigation_item.xml +11 -0
  57. data/support/mobile/android/app/src/main/res/drawable/ic_launcher_foreground.xml +26 -0
  58. data/support/mobile/android/app/src/main/res/drawable/ic_tab_placeholder.xml +28 -0
  59. data/support/mobile/android/app/src/main/res/layout/activity_main.xml +116 -0
  60. data/support/mobile/android/app/src/main/res/layout/bridge_menu_sheet.xml +83 -0
  61. data/support/mobile/android/app/src/main/res/layout/bridge_menu_sheet_item.xml +35 -0
  62. data/support/mobile/android/app/src/main/res/layout/tab_more_sheet.xml +48 -0
  63. data/support/mobile/android/app/src/main/res/layout/tab_more_sheet_item.xml +35 -0
  64. data/support/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +12 -0
  65. data/support/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +6 -0
  66. data/support/mobile/android/app/src/main/res/values/colors.xml +21 -0
  67. data/support/mobile/android/app/src/main/res/values/ic_launcher_background.xml +5 -0
  68. data/support/mobile/android/app/src/main/res/values/strings.xml +18 -0
  69. data/support/mobile/android/app/src/main/res/values/styles.xml +32 -0
  70. data/support/mobile/android/app/src/main/res/values/themes.xml +53 -0
  71. data/support/mobile/android/app/src/main/res/values-night/colors.xml +11 -0
  72. data/support/mobile/android/app/src/main/res/values-night/themes.xml +33 -0
  73. data/support/mobile/android/app/src/main/res/xml/network_security_config.xml +12 -0
  74. data/support/mobile/android/app/src/stamped/AndroidManifest.xml +34 -0
  75. data/support/mobile/android/build.gradle.kts +8 -0
  76. data/support/mobile/android/gradle/wrapper/gradle-wrapper.jar +0 -0
  77. data/support/mobile/android/gradle/wrapper/gradle-wrapper.properties +10 -0
  78. data/support/mobile/android/gradle.properties +14 -0
  79. data/support/mobile/android/gradlew +251 -0
  80. data/support/mobile/android/gradlew.bat +94 -0
  81. data/support/mobile/android/settings.gradle.kts +28 -0
  82. data/support/mobile/ios/App/AppDelegate.swift +5 -0
  83. data/support/mobile/ios/App/AuthFlow.swift +101 -0
  84. data/support/mobile/ios/App/EverywhereConfig.swift +84 -0
  85. data/support/mobile/ios/App/SceneDelegate.swift +103 -0
  86. data/support/mobile/ios/App.xcodeproj/project.pbxproj +4 -0
  87. data/support/mobile/ios/README.md +4 -1
  88. metadata +67 -1
@@ -0,0 +1,330 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "shellwords"
4
+ require_relative "shellout"
5
+ require_relative "ui"
6
+
7
+ module Everywhere
8
+ # Locates the Android toolchain — SDK, adb, emulator, sdkmanager, JDK — for
9
+ # the builder, the emulator driver, `every logs` and `every doctor`.
10
+ #
11
+ # The one rule everything here follows: resolve BY LOCATION, never off PATH.
12
+ # Android Studio provisions the whole toolchain (SDK, platform-tools, the
13
+ # emulator, system images, a bundled JDK) and exports none of it — no shell
14
+ # profile is touched, so a machine that can build Android apps all day has no
15
+ # `adb`, no `sdkmanager` and often no `java` on PATH at all. A `which adb`
16
+ # here would report "missing" on a perfectly provisioned Mac. Fixed locations
17
+ # under the SDK root are what's actually stable, the same way `xcrun` shields
18
+ # the iOS builder from wherever Xcode put its internals.
19
+ module AndroidSdk
20
+ module_function
21
+
22
+ # Android Gradle Plugin 8.x refuses to run on anything older, and the
23
+ # failure surfaces as an unreadable Gradle/Kotlin stacktrace rather than a
24
+ # version complaint — so we check before Gradle ever starts.
25
+ MINIMUM_JDK = 17
26
+
27
+ # The API level the shell template compiles against. Named here (not in the
28
+ # builder) because `every doctor` has to print the sdkmanager line for it
29
+ # long before a build is attempted.
30
+ COMPILE_SDK = 35
31
+
32
+ # Android Studio's bundled JetBrains Runtime, per platform. Studio never
33
+ # exports it, so these paths are the only way to find a JDK on a machine
34
+ # whose owner installed Studio and nothing else.
35
+ STUDIO_JBR = [
36
+ "/Applications/Android Studio.app/Contents/jbr/Contents/Home",
37
+ "~/Applications/Android Studio.app/Contents/jbr/Contents/Home",
38
+ "/opt/android-studio/jbr",
39
+ "/usr/local/android-studio/jbr",
40
+ "~/android-studio/jbr",
41
+ "C:/Program Files/Android/Android Studio/jbr"
42
+ ].freeze
43
+
44
+ # --- platform ------------------------------------------------------------
45
+
46
+ def macos? = RUBY_PLATFORM.include?("darwin")
47
+ def windows? = RUBY_PLATFORM.match?(/mswin|mingw|cygwin/)
48
+
49
+ # --- SDK root ------------------------------------------------------------
50
+
51
+ # Where the SDK lives on a default install, per platform. Studio writes
52
+ # exactly one of these and records it in its own settings — never in the
53
+ # environment — which is why the fallback matters more here than it would
54
+ # for a toolchain with an installer that edits your profile.
55
+ def default_sdk_roots
56
+ if macos?
57
+ ["~/Library/Android/sdk"]
58
+ elsif windows?
59
+ [ENV["LOCALAPPDATA"] && File.join(ENV["LOCALAPPDATA"], "Android", "Sdk")].compact
60
+ else
61
+ ["~/Android/Sdk"]
62
+ end
63
+ end
64
+
65
+ # The SDK root, or nil. An explicit env var wins so CI images and secondary
66
+ # SDKs work without moving anything; ANDROID_HOME comes first because it's
67
+ # the spelling Studio and the docs use now (ANDROID_SDK_ROOT is deprecated
68
+ # but still all over CI). A var pointing at a directory that isn't there is
69
+ # skipped rather than fatal — a stale export shouldn't hide the SDK sitting
70
+ # in the default location.
71
+ def sdk_root
72
+ [ENV["ANDROID_HOME"], ENV["ANDROID_SDK_ROOT"], *default_sdk_roots]
73
+ .reject { |dir| dir.to_s.empty? }
74
+ .map { |dir| File.expand_path(dir) }
75
+ .find { |dir| File.directory?(dir) }
76
+ end
77
+
78
+ def sdk_root!
79
+ sdk_root or UI.die!("no Android SDK — install Android Studio (it provisions the SDK, adb, " \
80
+ "the emulator and a JDK in one go), or point ANDROID_HOME at an " \
81
+ "existing SDK; looked in #{expected_sdk_root}")
82
+ end
83
+
84
+ # The path named in "we couldn't find it" messages, so the user is told
85
+ # where we looked rather than left to guess.
86
+ def expected_sdk_root
87
+ File.expand_path(default_sdk_roots.first || "~/Android/Sdk")
88
+ end
89
+
90
+ # --- tools ---------------------------------------------------------------
91
+
92
+ def adb = sdk_tool("platform-tools", "adb")
93
+ def emulator = sdk_tool("emulator", "emulator")
94
+
95
+ def adb!
96
+ adb or UI.die!("no adb under #{sdk_root!} — #{install_hint("platform-tools")}")
97
+ end
98
+
99
+ def emulator!
100
+ emulator or UI.die!("no Android emulator under #{sdk_root!} — #{install_hint("emulator")}")
101
+ end
102
+
103
+ def sdkmanager = cmdline_tool("sdkmanager")
104
+ def avdmanager = cmdline_tool("avdmanager")
105
+
106
+ def sdkmanager!
107
+ sdkmanager or UI.die!("no sdkmanager under #{sdk_root!} — #{cmdline_tools_hint}")
108
+ end
109
+
110
+ def avdmanager!
111
+ avdmanager or UI.die!("no avdmanager under #{sdk_root!} — #{cmdline_tools_hint}")
112
+ end
113
+
114
+ # Whether this SDK can install anything at all. Worth asking on its own,
115
+ # because it's the one component whose absence makes every other
116
+ # remediation unquotable: without it there is no `sdkmanager` to run, and
117
+ # the Phase 0 machine had a complete SDK, adb, the emulator, build-tools and
118
+ # an AVD with no cmdline-tools anywhere — not in the SDK, not inside Android
119
+ # Studio. So a missing one is an advisory, not a broken machine.
120
+ def cmdline_tools? = !sdkmanager.nil?
121
+
122
+ # How to get command-line tools without already having them. The Studio
123
+ # route is first because it also accepts the licences, but the manual zip
124
+ # matters: it's the only answer for a machine with no Studio, and Studio's
125
+ # own SDK Tools list is exactly where this component was already unchecked.
126
+ CMDLINE_TOOLS_URL = "https://developer.android.com/studio#command-line-tools-only"
127
+
128
+ def cmdline_tools_hint
129
+ "install \"Android SDK Command-line Tools (latest)\" in Android Studio → Settings → " \
130
+ "Languages & Frameworks → Android SDK → SDK Tools, or download " \
131
+ "commandlinetools-#{host_tag}-*_latest.zip from #{CMDLINE_TOOLS_URL} and unzip it into " \
132
+ "#{cmdline_tools_dir} (so its bin/ lands there)"
133
+ end
134
+
135
+ # Where an unzipped command-line-tools release has to end up. Named because
136
+ # both hints point at it, and because "unzip it somewhere in the SDK" is
137
+ # the instruction people get wrong — the tools look for their own jars
138
+ # relative to this exact directory.
139
+ def cmdline_tools_dir
140
+ File.join(sdk_root || expected_sdk_root, "cmdline-tools", "latest")
141
+ end
142
+
143
+ # The token Google's command-line-tools downloads are named with.
144
+ def host_tag
145
+ return "mac" if macos?
146
+
147
+ windows? ? "win" : "linux"
148
+ end
149
+
150
+ # A tool at its fixed spot under the SDK root, or nil.
151
+ def sdk_tool(subdir, name)
152
+ root = sdk_root or return nil
153
+
154
+ tool_in(File.join(root, subdir), name)
155
+ end
156
+
157
+ # sdkmanager and avdmanager are the one pair that moved: current installs
158
+ # put them in cmdline-tools/latest/bin, an SDK pinned to a specific
159
+ # command-line-tools release uses cmdline-tools/<version>/bin, and anything
160
+ # provisioned before 2021 still has the legacy tools/bin copy. Try all three
161
+ # so `every doctor` can print a working install line on an older machine
162
+ # instead of a path that doesn't exist.
163
+ def cmdline_tool(name)
164
+ root = sdk_root or return nil
165
+
166
+ # Reverse-sorted so 12.0 beats 9.0 in the common case; "latest" is tried
167
+ # first anyway, so this ordering only decides between pinned releases.
168
+ versioned = Dir.glob(File.join(root, "cmdline-tools", "*", "bin")).sort.reverse
169
+ dirs = [File.join(root, "cmdline-tools", "latest", "bin"), *versioned,
170
+ File.join(root, "tools", "bin")]
171
+ dirs.uniq.filter_map { |dir| tool_in(dir, name) }.first
172
+ end
173
+
174
+ # Windows spells the same tools adb.exe and sdkmanager.bat; probing the
175
+ # suffixes here lets every caller ask for the plain Unix name.
176
+ def tool_in(dir, name)
177
+ [name, "#{name}.exe", "#{name}.bat"]
178
+ .map { |file| File.join(dir, file) }
179
+ .find { |path| File.file?(path) }
180
+ end
181
+
182
+ # A ready-to-paste instruction for installing a missing SDK package. When
183
+ # the SDK has no command-line tools at all there IS no command to paste, so
184
+ # say what to click in Studio instead — an honest instruction beats a
185
+ # copyable line that resolves to nothing. Phrased to read on its own, since
186
+ # callers append it after an em dash.
187
+ def install_hint(package)
188
+ if (manager = sdkmanager)
189
+ "run: #{manager.shellescape} #{package.inspect}"
190
+ else
191
+ # The short form of cmdline_tools_hint: doctor prints the full version
192
+ # once, above these lines, and repeating a URL and two absolute paths
193
+ # on every missing package buries what each line is actually about.
194
+ "install \"Android SDK Command-line Tools\" (Android Studio → SDK Tools, or unzip " \
195
+ "commandlinetools-#{host_tag}-*_latest.zip into #{cmdline_tools_dir}), " \
196
+ "then run: sdkmanager #{package.inspect}"
197
+ end
198
+ end
199
+
200
+ # --- JDK -----------------------------------------------------------------
201
+
202
+ # Gradle needs a JDK. JAVA_HOME wins because it's the one explicit choice on
203
+ # the machine; then macOS's own registry (which knows about JDKs installed
204
+ # by brew, Oracle or SDKMAN without any of them being on PATH); then
205
+ # Studio's bundled runtime, which is what a Studio-only machine actually has.
206
+ def java_home
207
+ explicit = ENV["JAVA_HOME"].to_s
208
+ return File.expand_path(explicit) if !explicit.empty? && File.directory?(explicit)
209
+
210
+ registry_java_home || studio_java_home
211
+ end
212
+
213
+ # Like #java_home but also enforces the minimum: a JDK 11 that resolves
214
+ # cleanly still fails the build later, deep inside Gradle, so reject it here
215
+ # where the message can name the cause.
216
+ def java_home!(minimum: MINIMUM_JDK)
217
+ home = java_home or
218
+ UI.die!("no JDK — install Android Studio (it bundles one at #{STUDIO_JBR.first}) " \
219
+ "or set JAVA_HOME to a JDK #{minimum} or newer")
220
+ version = java_version
221
+ return home if version.nil? || version >= minimum
222
+
223
+ UI.die!("#{home} is JDK #{version}; the Android Gradle Plugin needs #{minimum} or newer — " \
224
+ "set JAVA_HOME to a JDK #{minimum}+, or install Android Studio and use its bundled runtime")
225
+ end
226
+
227
+ # The JDK's major version, or nil if it can't be determined. Read from the
228
+ # `release` file the JDK ships rather than by running `java -version`: it's
229
+ # a file read instead of a JVM start, and it works for a JDK that isn't on
230
+ # PATH — which, per the rule at the top, is most of them. A JDK 8 reports
231
+ # JAVA_VERSION="1.8.0_412", so this yields 1, which compares correctly
232
+ # against MINIMUM_JDK without special-casing the old scheme.
233
+ def java_version
234
+ home = java_home or return nil
235
+ release = File.join(home, "release")
236
+ return File.read(release)[/^JAVA_VERSION="?(\d+)/, 1]&.to_i if File.file?(release)
237
+
238
+ java = tool_in(File.join(home, "bin"), "java") or return nil
239
+ out, status = Shellout.capture(java, "-version")
240
+ out[/version "?(\d+)/, 1]&.to_i if status&.success?
241
+ end
242
+
243
+ # macOS keeps a registry of installed JDKs that java_home queries; -v 17
244
+ # asks for "17 or newer", so a machine with only JDK 21 still answers.
245
+ def registry_java_home
246
+ return nil unless macos?
247
+
248
+ out, status = Shellout.capture("/usr/libexec/java_home", "-v", MINIMUM_JDK.to_s)
249
+ home = out.to_s.strip
250
+ home if status&.success? && !home.empty? && File.directory?(home)
251
+ end
252
+
253
+ def studio_java_home
254
+ STUDIO_JBR.map { |dir| File.expand_path(dir) }.find { |dir| File.directory?(dir) }
255
+ end
256
+
257
+ # --- installed packages --------------------------------------------------
258
+
259
+ # Which platforms/build-tools are on disk, read straight off the filesystem
260
+ # rather than from `sdkmanager --list`: that's a multi-second JVM start that
261
+ # also reaches out for the remote catalogue we don't care about, and it
262
+ # needs the command-line tools, which are exactly the component most likely
263
+ # to be missing on a Studio-only machine.
264
+ def installed_platforms = sdk_package_dirs("platforms")
265
+ def installed_build_tools = sdk_package_dirs("build-tools")
266
+
267
+ def sdk_package_dirs(kind)
268
+ root = sdk_root or return []
269
+
270
+ Dir.glob(File.join(root, kind, "*"))
271
+ .select { |dir| File.directory?(dir) }
272
+ .map { |dir| File.basename(dir) }
273
+ .sort
274
+ end
275
+
276
+ # `platform?(35)` and `platform?("android-35")` both work — callers spell it
277
+ # whichever way reads better where they are.
278
+ def platform?(api = COMPILE_SDK)
279
+ name = api.to_s.start_with?("android-") ? api.to_s : "android-#{api}"
280
+ installed_platforms.include?(name)
281
+ end
282
+
283
+ def platform!(api = COMPILE_SDK)
284
+ return true if platform?(api)
285
+
286
+ package = "platforms;android-#{api.to_s.delete_prefix("android-")}"
287
+ UI.die!("the Android SDK has no #{package} (installed: " \
288
+ "#{installed_platforms.empty? ? "none" : installed_platforms.join(", ")}) — " \
289
+ "#{install_hint(package)}")
290
+ end
291
+
292
+ # --- AVDs ----------------------------------------------------------------
293
+
294
+ # Virtual devices, newest-created last. Asked of the emulator binary rather
295
+ # than globbed out of ~/.android/avd because the emulator also honours
296
+ # ANDROID_AVD_HOME and ANDROID_SDK_HOME — and an AVD it won't list is one we
297
+ # couldn't boot anyway. Studio's Device Manager writes to the same place, so
298
+ # a VM created in the GUI shows up here with no extra step.
299
+ def avds
300
+ exe = emulator or return []
301
+
302
+ out, status = Shellout.capture(exe, "-list-avds")
303
+ return [] unless status&.success?
304
+
305
+ # The emulator prints INFO/WARNING banners on the same stream; AVD names
306
+ # can only contain [A-Za-z0-9._-], so anything with a space is chatter.
307
+ out.lines.map(&:strip).reject(&:empty?).grep(/\A[\w.-]+\z/)
308
+ end
309
+
310
+ def avds!
311
+ found = avds
312
+ return found if found.any?
313
+
314
+ UI.die!("no Android virtual devices — #{create_avd_hint}")
315
+ end
316
+
317
+ # How to get a bootable AVD. The GUI route comes first because a machine
318
+ # with Studio installed almost always has a device defined already, and
319
+ # Device Manager also downloads the system image the CLI line assumes.
320
+ def create_avd_hint
321
+ "create one in Android Studio → Device Manager, or run: " \
322
+ "#{(avdmanager || "avdmanager").shellescape} create avd -n Pixel_API_#{COMPILE_SDK} " \
323
+ "-k \"system-images;android-#{COMPILE_SDK};google_apis;#{host_abi}\""
324
+ end
325
+
326
+ # The system image ABI that runs at native speed on this host — an x86_64
327
+ # image on Apple silicon boots, technically, at a speed nobody will wait for.
328
+ def host_abi = RUBY_PLATFORM.match?(/arm64|aarch64/) ? "arm64-v8a" : "x86_64"
329
+ end
330
+ end
@@ -0,0 +1,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+
6
+ module Everywhere
7
+ # Compiles an app's native/ios/assets/ tree into Assets.xcassets imagesets so
8
+ # native code can reach them by name — Image("logo") in SwiftUI,
9
+ # UIImage(named: "logo") in UIKit. The catalog already ships in the frozen
10
+ # template and is compiled by actool, so writing imagesets into it keeps the
11
+ # stamp-only contract (the pbxproj is never touched).
12
+ #
13
+ # Filenames are the whole API: <name>[@2x|@3x][~dark].<ext>. Sub-directories
14
+ # organise the catalog but do NOT namespace it — asset names stay global, so
15
+ # assets/branding/logo.png is still Image("logo").
16
+ module AssetCatalog
17
+ RASTER = %w[.png .jpg .jpeg].freeze
18
+ VECTOR = %w[.pdf .svg].freeze
19
+ EXTENSIONS = (RASTER + VECTOR).freeze
20
+
21
+ # Written by the iOS builder itself (icon, launch background, tint) — an
22
+ # app asset of the same name would silently lose to, or clobber, those.
23
+ RESERVED = %w[AppIcon AccentColor LaunchBackground].freeze
24
+
25
+ VALID_NAME = /\A[A-Za-z0-9_][A-Za-z0-9_.-]*\z/
26
+ INFO = { "author" => "xcode", "version" => 1 }.freeze
27
+
28
+ class << self
29
+ # Copy every image under `source` into `catalog` as an imageset. Returns
30
+ # [names, errors]; nothing is written when errors is non-empty, so a
31
+ # broken assets/ tree can't half-stamp a catalog.
32
+ def write!(source, catalog)
33
+ sets, errors = plan(source)
34
+ return [[], errors] unless errors.empty?
35
+
36
+ sets.each do |set|
37
+ group = set[:dir] == "." ? catalog : File.join(catalog, set[:dir])
38
+ dir = File.join(group, "#{set[:name]}.imageset")
39
+ FileUtils.mkdir_p(dir)
40
+ set[:variants].each { |v| FileUtils.cp(v[:path], File.join(dir, File.basename(v[:path]))) }
41
+ File.write(File.join(dir, "Contents.json"), "#{JSON.pretty_generate(contents(set))}\n")
42
+ write_group_folders(catalog, set[:dir])
43
+ end
44
+
45
+ [sets.map { |set| set[:name] }.sort, []]
46
+ end
47
+
48
+ private
49
+
50
+ # Group the tree into one imageset per name, validating as we go. Errors
51
+ # are collected (not raised) so one `every build` reports every problem.
52
+ def plan(source)
53
+ errors = []
54
+ files = Dir.glob(File.join(source, "**", "*"))
55
+ .select { |path| File.file?(path) && !File.basename(path).start_with?(".") }
56
+ .sort
57
+ images, other = files.partition { |path| EXTENSIONS.include?(File.extname(path).downcase) }
58
+
59
+ other.each do |path|
60
+ errors << "#{relative(path, source)}: unsupported image type — use #{EXTENSIONS.join(", ")}"
61
+ end
62
+ variants = images.filter_map { |path| variant(path, source, errors) }
63
+
64
+ sets = variants.group_by { |v| v[:name] }.map do |name, group|
65
+ dirs = group.map { |v| v[:dir] }.uniq
66
+ if dirs.length > 1
67
+ errors << "#{name}: same asset name in #{dirs.map { |d| d == "." ? "assets/" : "assets/#{d}/" }.join(" and ")} " \
68
+ "— names are global in the catalog, so rename one"
69
+ end
70
+ errors.concat(slot_errors(name, group))
71
+ { name: name, dir: dirs.first, variants: group }
72
+ end
73
+
74
+ [sets, errors]
75
+ end
76
+
77
+ def variant(path, source, errors)
78
+ relative = relative(path, source)
79
+ extension = File.extname(path).downcase
80
+ name, scale, dark = parse(File.basename(path, File.extname(path)))
81
+
82
+ unless name.match?(VALID_NAME)
83
+ errors << "#{relative}: #{name.inspect} isn't a usable asset name — " \
84
+ "use letters, digits, _, - and ."
85
+ return nil
86
+ end
87
+ if RESERVED.include?(name)
88
+ errors << "#{relative}: #{name} is reserved (stamped from config/everywhere.yml) — rename it"
89
+ return nil
90
+ end
91
+ if scale && VECTOR.include?(extension)
92
+ errors << "#{relative}: vector assets are scale-free — drop the @#{scale} suffix"
93
+ return nil
94
+ end
95
+
96
+ { name: name, dir: File.dirname(relative), scale: scale, dark: dark,
97
+ vector: VECTOR.include?(extension), path: path, relative: relative }
98
+ end
99
+
100
+ # Trailing @2x/@3x and ~dark markers, in either order, off the stem.
101
+ def parse(base)
102
+ name = base.dup
103
+ scale = nil
104
+ dark = false
105
+ loop do
106
+ if name.sub!(/~dark\z/, "")
107
+ dark = true
108
+ elsif name.sub!(/@([23])x\z/, "")
109
+ scale = "#{Regexp.last_match(1)}x"
110
+ else
111
+ break
112
+ end
113
+ end
114
+ [name, scale, dark]
115
+ end
116
+
117
+ def slot_errors(name, group)
118
+ errors = []
119
+ if group.map { |v| v[:vector] }.uniq.length > 1
120
+ errors << "#{name}: mixes vector and raster files — pick one per asset"
121
+ end
122
+ group.group_by { |v| [v[:scale], v[:dark]] }.each_value do |clash|
123
+ next if clash.length < 2
124
+
125
+ errors << "#{name}: #{clash.map { |v| v[:relative] }.join(" and ")} both claim the same slot"
126
+ end
127
+ errors
128
+ end
129
+
130
+ # Two shapes: a 1x/2x/3x set when any file carries a scale suffix (empty
131
+ # slots are declared, which is what Xcode itself writes), otherwise a
132
+ # single-scale set — one file used at every scale. Bare filenames get
133
+ # single-scale so a 3x-resolution export isn't silently treated as 1x.
134
+ def contents(set)
135
+ images =
136
+ if set[:variants].any? { |v| v[:scale] }
137
+ %w[1x 2x 3x].flat_map { |scale| slots(set, scale) }
138
+ else
139
+ slots(set, nil)
140
+ end
141
+
142
+ { "images" => images, "info" => INFO }
143
+ end
144
+
145
+ def slots(set, scale)
146
+ [false, true].filter_map do |dark|
147
+ # A light slot always exists (Xcode declares empty ones); a dark slot
148
+ # only when the app actually shipped a ~dark file for it.
149
+ file = set[:variants].find { |v| v[:dark] == dark && (scale.nil? || (v[:scale] || "1x") == scale) }
150
+ next if dark && file.nil?
151
+
152
+ entry = { "idiom" => "universal" }
153
+ entry["appearances"] = [{ "appearance" => "luminosity", "value" => "dark" }] if dark
154
+ entry["filename"] = File.basename(file[:path]) if file
155
+ entry["scale"] = scale if scale
156
+ entry["properties"] = { "preserves-vector-representation" => true } if file && file[:vector]
157
+ entry
158
+ end
159
+ end
160
+
161
+ # Plain catalog folders (no provides-namespace) — organisation only, so
162
+ # assets/branding/logo.png stays Image("logo").
163
+ def write_group_folders(catalog, dir)
164
+ return if dir == "."
165
+
166
+ path_prefixes(dir).each do |prefix|
167
+ path = File.join(catalog, prefix)
168
+ FileUtils.mkdir_p(path)
169
+ contents = File.join(path, "Contents.json")
170
+ File.write(contents, "#{JSON.pretty_generate("info" => INFO)}\n") unless File.exist?(contents)
171
+ end
172
+ end
173
+
174
+ def path_prefixes(dir)
175
+ dir.split(File::SEPARATOR).each_with_object([]) do |part, acc|
176
+ acc << (acc.empty? ? part : File.join(acc.last, part))
177
+ end
178
+ end
179
+
180
+ def relative(path, source)
181
+ path.delete_prefix("#{source}/")
182
+ end
183
+ end
184
+ end
185
+ end