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.
- checksums.yaml +4 -4
- data/README.md +255 -0
- data/bridge/everywhere/bridge.js +60 -3
- data/lib/everywhere/android_resources.rb +217 -0
- data/lib/everywhere/android_sdk.rb +330 -0
- data/lib/everywhere/asset_catalog.rb +185 -0
- data/lib/everywhere/auth_handoff.rb +398 -0
- data/lib/everywhere/auth_token.rb +118 -0
- data/lib/everywhere/builders/android.rb +844 -0
- data/lib/everywhere/builders/ios.rb +40 -3
- data/lib/everywhere/commands/build.rb +51 -9
- data/lib/everywhere/commands/clean.rb +10 -5
- data/lib/everywhere/commands/dev.rb +46 -8
- data/lib/everywhere/commands/doctor.rb +89 -3
- data/lib/everywhere/commands/install.rb +1 -1
- data/lib/everywhere/commands/logs.rb +117 -8
- data/lib/everywhere/config.rb +409 -13
- data/lib/everywhere/emulator.rb +405 -0
- data/lib/everywhere/engine.rb +12 -0
- data/lib/everywhere/icon.rb +196 -0
- data/lib/everywhere/log_filter.rb +74 -0
- data/lib/everywhere/native_helper.rb +2 -5
- data/lib/everywhere/omniauth.rb +88 -0
- data/lib/everywhere/paths.rb +47 -0
- data/lib/everywhere/version.rb +1 -1
- data/support/mobile/android/README.md +217 -0
- data/support/mobile/android/app/build.gradle.kts +156 -0
- data/support/mobile/android/app/everywhere.properties +8 -0
- data/support/mobile/android/app/native-packages.gradle.kts +16 -0
- data/support/mobile/android/app/proguard-rules.pro +21 -0
- data/support/mobile/android/app/src/debug/res/xml/network_security_config.xml +30 -0
- data/support/mobile/android/app/src/main/AndroidManifest.xml +42 -0
- data/support/mobile/android/app/src/main/assets/everywhere.json +9 -0
- data/support/mobile/android/app/src/main/assets/fonts/MaterialIcons-Regular.codepoints +2235 -0
- data/support/mobile/android/app/src/main/assets/fonts/MaterialIcons-Regular.ttf +0 -0
- data/support/mobile/android/app/src/main/assets/json/path-configuration.json +19 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/AuthFlow.kt +268 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereApplication.kt +178 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereConfig.kt +409 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereEvents.kt +68 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereHost.kt +101 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/IconFont.kt +211 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/MainActivity.kt +975 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/MainTabs.kt +201 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/WebBottomSheetFragment.kt +13 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/WebFragment.kt +36 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/BiometricsComponent.kt +535 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/HapticsComponent.kt +141 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/MenuComponent.kt +361 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/NotificationComponent.kt +151 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/PermissionsComponent.kt +271 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/StorageComponent.kt +101 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/WebControlChannel.kt +204 -0
- data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/extensions/EverywhereExtensions.kt +51 -0
- data/support/mobile/android/app/src/main/res/color/bottom_navigation_active_indicator.xml +4 -0
- data/support/mobile/android/app/src/main/res/color/bottom_navigation_item.xml +11 -0
- data/support/mobile/android/app/src/main/res/drawable/ic_launcher_foreground.xml +26 -0
- data/support/mobile/android/app/src/main/res/drawable/ic_tab_placeholder.xml +28 -0
- data/support/mobile/android/app/src/main/res/layout/activity_main.xml +116 -0
- data/support/mobile/android/app/src/main/res/layout/bridge_menu_sheet.xml +83 -0
- data/support/mobile/android/app/src/main/res/layout/bridge_menu_sheet_item.xml +35 -0
- data/support/mobile/android/app/src/main/res/layout/tab_more_sheet.xml +48 -0
- data/support/mobile/android/app/src/main/res/layout/tab_more_sheet_item.xml +35 -0
- data/support/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +12 -0
- data/support/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +6 -0
- data/support/mobile/android/app/src/main/res/values/colors.xml +21 -0
- data/support/mobile/android/app/src/main/res/values/ic_launcher_background.xml +5 -0
- data/support/mobile/android/app/src/main/res/values/strings.xml +18 -0
- data/support/mobile/android/app/src/main/res/values/styles.xml +32 -0
- data/support/mobile/android/app/src/main/res/values/themes.xml +53 -0
- data/support/mobile/android/app/src/main/res/values-night/colors.xml +11 -0
- data/support/mobile/android/app/src/main/res/values-night/themes.xml +33 -0
- data/support/mobile/android/app/src/main/res/xml/network_security_config.xml +12 -0
- data/support/mobile/android/app/src/stamped/AndroidManifest.xml +34 -0
- data/support/mobile/android/build.gradle.kts +8 -0
- data/support/mobile/android/gradle/wrapper/gradle-wrapper.jar +0 -0
- data/support/mobile/android/gradle/wrapper/gradle-wrapper.properties +10 -0
- data/support/mobile/android/gradle.properties +14 -0
- data/support/mobile/android/gradlew +251 -0
- data/support/mobile/android/gradlew.bat +94 -0
- data/support/mobile/android/settings.gradle.kts +28 -0
- data/support/mobile/ios/App/AppDelegate.swift +5 -0
- data/support/mobile/ios/App/AuthFlow.swift +101 -0
- data/support/mobile/ios/App/EverywhereConfig.swift +84 -0
- data/support/mobile/ios/App/SceneDelegate.swift +103 -0
- data/support/mobile/ios/App.xcodeproj/project.pbxproj +4 -0
- data/support/mobile/ios/README.md +4 -1
- 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
|