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
@@ -5,6 +5,7 @@ require_relative "../paths"
5
5
  require_relative "../shellout"
6
6
  require_relative "../log_filter"
7
7
  require_relative "../icon"
8
+ require_relative "../asset_catalog"
8
9
  require_relative "../ui"
9
10
 
10
11
  module Everywhere
@@ -72,9 +73,9 @@ module Everywhere
72
73
  end
73
74
 
74
75
  # 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.
76
+ # (runtime config + path configuration with baked tabs), the asset catalog
77
+ # (icon + app images), and the Extensions/ buildable folder (app-authored
78
+ # Swift + generated registry). The pbxproj is never touched.
78
79
  def stamp!(work)
79
80
  UI.step("stamping shell #{UI.dim("(#{bundle_id} → #{UI.short_path(work)})")}")
80
81
  write_xcconfig(work)
@@ -86,8 +87,10 @@ module Everywhere
86
87
  @config.path_configuration_json("ios"))
87
88
  write_launch_background(work)
88
89
  write_usage_strings(work)
90
+ write_url_scheme(work)
89
91
  write_entitlements(work)
90
92
  write_app_icon(work)
93
+ write_asset_catalog(work)
91
94
  write_extensions(work)
92
95
  write_native_packages(work)
93
96
  end
@@ -291,6 +294,25 @@ module Everywhere
291
294
  end
292
295
  end
293
296
 
297
+ # The custom URL scheme third-party sign-in returns through
298
+ # (everywhere.yml auth:) → Info.plist. ASWebAuthenticationSession claims
299
+ # the callback for itself while it's running, so this registration isn't
300
+ # what makes the flow work — it's what stops another app from quietly
301
+ # owning the scheme, and what makes `xcrun simctl openurl` usable for
302
+ # testing the return leg by hand.
303
+ def write_url_scheme(work)
304
+ errors = @config.auth_errors
305
+ UI.die!("auth in config/everywhere.yml:\n#{errors.join("\n")}") unless errors.empty?
306
+ return unless @config.oauth?
307
+
308
+ require "json"
309
+ scheme = @config.auth_scheme
310
+ UI.step("stamping sign-in callback scheme #{UI.dim("(#{scheme}://)")}")
311
+ Shellout.run!({}, "plutil", "-replace", "CFBundleURLTypes", "-json",
312
+ JSON.generate([{ "CFBundleURLName" => bundle_id, "CFBundleURLSchemes" => [scheme] }]),
313
+ File.join(work, "App", "Info.plist"))
314
+ end
315
+
294
316
  # The launch-screen (and splash overlay) background — the storyboard's
295
317
  # LaunchBackground named color, stamped from appearance.background_color
296
318
  # so the launch frame matches the app instead of the template default.
@@ -357,6 +379,21 @@ module Everywhere
357
379
  UI.warn "couldn't read #{File.basename(source)} — shipping the placeholder icon"
358
380
  end
359
381
 
382
+ # App-supplied images: native/ios/assets/** → imagesets in the template's
383
+ # Assets.xcassets, so native/ios Swift code reaches them by filename with
384
+ # Image("logo") / UIImage(named: "logo").
385
+ def write_asset_catalog(work)
386
+ source = File.join(@root, "native", "ios", "assets")
387
+ return unless File.directory?(source)
388
+
389
+ names, errors = AssetCatalog.write!(source, File.join(work, "App", "Assets.xcassets"))
390
+ UI.die!("native/ios/assets:\n#{errors.join("\n")}") unless errors.empty?
391
+ return if names.empty?
392
+
393
+ listed = names.length > 6 ? "#{names.take(6).join(", ")}, …" : names.join(", ")
394
+ UI.step("bundling image assets #{UI.dim("(#{names.length} from native/ios/assets: #{listed})")}")
395
+ end
396
+
360
397
  def xcodebuild!(work, configuration, dist_dir)
361
398
  UI.step("building iOS shell #{UI.dim("(xcodebuild, #{configuration}, first run compiles HotwireNative)")}")
362
399
  FileUtils.mkdir_p(dist_dir)
@@ -10,6 +10,7 @@ require_relative "../png"
10
10
  require_relative "../icon"
11
11
  require_relative "../paths"
12
12
  require_relative "../builders/ios"
13
+ require_relative "../builders/android"
13
14
 
14
15
  module Everywhere
15
16
  module Commands
@@ -24,34 +25,47 @@ module Everywhere
24
25
  class Build < Dry::CLI::Command
25
26
  SHIM = File.expand_path("~/.tebako-shims/clang++")
26
27
 
27
- desc "Package the app natively — desktop (tebako press + shell + .app) or iOS (--target ios-arm64)"
28
+ DEFAULT_TARGET = "macos-arm64"
29
+ IOS_TARGET = "ios-arm64"
30
+ # The APK/AAB is universal, so the arch half is nominal — it exists only
31
+ # so every target reads as os-arch.
32
+ ANDROID_TARGET = "android-arm64"
33
+
34
+ # The --ios/--android shorthands and the os they stand for, so adding a
35
+ # mobile platform is one entry rather than a third copy of resolve_target.
36
+ SHORTHAND_TARGETS = { ios: IOS_TARGET, android: ANDROID_TARGET }.freeze
37
+
38
+ desc "Package the app natively — desktop (tebako press + shell + .app), iOS (--ios) or Android (--android)"
28
39
 
29
40
  option :root, default: ".", desc: "App root directory"
30
41
  option :output, default: nil, desc: "Output binary path (default: dist/<app>)"
31
42
  option :ruby, default: "4.0.6", desc: "Ruby version to package"
32
43
  option :entry, default: "native_boot.rb", desc: "Entry point script relative to root"
33
44
  option :app_name, default: nil, desc: "Bundle name (default: capitalized app dir)"
34
- option :target, default: "macos-arm64", desc: "Build target (os-arch) — selects any platforms: overrides"
45
+ option :target, default: nil, desc: "Build target (os-arch) — selects any platforms: overrides, default: \"#{DEFAULT_TARGET}\""
35
46
  option :shell_dir, default: nil, desc: "Path to the shell's src-tauri directory"
47
+ option :ios, type: :boolean, default: false, desc: "Build the iOS shell (shorthand for --target #{IOS_TARGET})"
48
+ option :android, type: :boolean, default: false, desc: "Build the Android shell (shorthand for --target #{ANDROID_TARGET})"
36
49
  option :skip_press, type: :boolean, default: false, desc: "Reuse the existing pressed binary, only rebuild the .app"
37
50
 
38
51
  def call(root: ".", output: nil, ruby: "4.0.6", entry: "native_boot.rb",
39
- app_name: nil, target: "macos-arm64", shell_dir: nil, skip_press: false, **)
52
+ app_name: nil, target: nil, shell_dir: nil, ios: false, android: false, skip_press: false, **)
53
+ target = resolve_target(target, ios: ios, android: android)
40
54
  root_path = File.expand_path(root)
41
55
  config = Everywhere::Config.load(root_path)
42
56
 
43
57
  # 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.
58
+ # the tebako+tauri path below; the mobile targets are Hotwire Native
59
+ # shells, each built by its own builder. Anything else has no builder yet.
46
60
  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))
61
+ when "ios", "android"
62
+ app = mobile_builder(target).new(config: config, root: root_path, target: target)
63
+ .build!(dist_dir: File.expand_path("dist", root_path))
50
64
  UI.success("built #{rel(app, root_path)}")
51
65
  return
52
66
  when "macos" # the rest of this method
53
67
  else
54
- UI.die!("no builder for target #{target} — macos-* and ios-* are supported")
68
+ UI.die!("no builder for target #{target} — macos-*, ios-* and android-* are supported")
55
69
  end
56
70
 
57
71
  # The os of `target` selects any per-platform overrides (bundle id,
@@ -95,6 +109,34 @@ module Everywhere
95
109
 
96
110
  private
97
111
 
112
+ # `--ios` is the shorthand `every dev` already has; --target stays the
113
+ # general escape hatch. Both given is only an error when they disagree —
114
+ # `--ios --target ios-arm64` is redundant, not wrong.
115
+ def mobile_builder(target)
116
+ Everywhere::Config.os_of(target) == "android" ? Builders::Android : Builders::Ios
117
+ end
118
+
119
+ # --ios/--android are shorthand for their target, and disagreeing with an
120
+ # explicit --target is a mistake worth naming rather than silently
121
+ # resolving one way: both spellings were deliberate, so we can't know
122
+ # which one the user meant.
123
+ def resolve_target(target, ios:, android:)
124
+ asked = { ios: ios, android: android }.select { |_flag, given| given }.keys
125
+ if asked.length > 1
126
+ UI.die!("#{asked.map { |f| "--#{f}" }.join(" and ")} are separate builds — run one at a time")
127
+ end
128
+
129
+ flag = asked.first or return target || DEFAULT_TARGET
130
+ shorthand = SHORTHAND_TARGETS.fetch(flag)
131
+ return shorthand if target.nil?
132
+
133
+ unless Everywhere::Config.os_of(target) == flag.to_s
134
+ UI.die!("--#{flag} and --target #{target} disagree — drop one " \
135
+ "(--#{flag} means --target #{shorthand})")
136
+ end
137
+ target
138
+ end
139
+
98
140
  def rel(path, root_path) = UI.short_path(path.sub("#{root_path}/", "").sub("#{Dir.pwd}/", ""))
99
141
 
100
142
  # The precompile step runs against the HOST Ruby — unlike tebako press,
@@ -6,17 +6,22 @@ require "fileutils"
6
6
  module Everywhere
7
7
  module Commands
8
8
  # Wipes the shared build caches under ~/.rubyeverywhere (or
9
- # $RUBYEVERYWHERE_HOME): the cargo shell cache plus the iOS DerivedData /
10
- # SPM / stamped-project dirs. Safe to run any time: only compiled shell
11
- # output lives there, so the next `every dev`/`every build` recompiles cold.
9
+ # $RUBYEVERYWHERE_HOME): the cargo shell cache, the iOS DerivedData / SPM /
10
+ # stamped-project dirs, and the Android Gradle home / icon font / stamped
11
+ # projects. Safe to run any time: everything here is derived output, so the
12
+ # next `every dev`/`every build` rebuilds it — cold, and for Android that
13
+ # includes re-downloading Gradle and the Material icon font.
12
14
  class Clean < Dry::CLI::Command
13
- desc "Remove the shell build caches (~/.rubyeverywhere: shell-target, ios-*)"
15
+ desc "Remove the shell build caches (~/.rubyeverywhere: shell-target, ios-*, android-*)"
14
16
 
15
17
  def call(**)
16
18
  caches = [Everywhere::Paths.cargo_target_dir,
17
19
  Everywhere::Paths.ios_derived_data_dir,
18
20
  Everywhere::Paths.ios_packages_dir,
19
- File.join(Everywhere::Paths.cache_dir, "ios")].select { |d| File.exist?(d) }
21
+ File.join(Everywhere::Paths.cache_dir, "ios"),
22
+ Everywhere::Paths.android_gradle_home,
23
+ Everywhere::Paths.android_fonts_dir,
24
+ File.join(Everywhere::Paths.cache_dir, "android")].select { |d| File.exist?(d) }
20
25
  return UI.step("nothing to clean #{UI.dim("(no build caches under #{Everywhere::Paths.cache_dir})")}") if caches.empty?
21
26
 
22
27
  freed = 0
@@ -6,7 +6,9 @@ require "shellwords"
6
6
  require_relative "../shellout"
7
7
  require_relative "../paths"
8
8
  require_relative "../builders/ios"
9
+ require_relative "../builders/android"
9
10
  require_relative "../simulator"
11
+ require_relative "../emulator"
10
12
  require_relative "logs"
11
13
 
12
14
  module Everywhere
@@ -21,16 +23,24 @@ module Everywhere
21
23
  class Dev < Dry::CLI::Command
22
24
  desc "Run the dev server with native shells on demand (live reload, no packaging)"
23
25
 
26
+ IOS_TARGET = "ios-arm64"
27
+ ANDROID_TARGET = "android-arm64"
28
+
24
29
  option :port, default: "3000", desc: "Dev server port"
25
30
  option :shell_dir, default: nil, desc: "Path to the shell's src-tauri directory"
26
- option :target, default: nil, desc: "Single shell to open: macos (desktop) or ios (Simulator)"
31
+ option :target, default: nil, desc: "Single shell to open: macos (desktop), ios (Simulator) or android (emulator)"
27
32
  option :desktop, type: :boolean, default: false, desc: "Open the desktop shell"
28
33
  option :ios, type: :boolean, default: false, desc: "Open the iOS Simulator shell"
34
+ option :android, type: :boolean, default: false, desc: "Open the Android emulator shell"
29
35
  option :browser, type: :boolean, default: false, desc: "Open the app in the default browser"
36
+ option :dev_url, default: nil, desc: "Explicit URL for the device to load (e.g. a LAN address for a physical Android device)"
30
37
 
31
- def call(port: "3000", shell_dir: nil, target: nil, desktop: false, ios: false, browser: false, **)
38
+ def call(port: "3000", shell_dir: nil, target: nil, desktop: false, ios: false, android: false,
39
+ browser: false, dev_url: nil, **)
32
40
  @shell_dir = shell_dir
33
- targets = requested_targets(target, desktop: desktop, ios: ios, browser: browser)
41
+ @port = port
42
+ @dev_url_override = dev_url
43
+ targets = requested_targets(target, desktop: desktop, ios: ios, android: android, browser: browser)
34
44
  framework, @config = detect_app
35
45
 
36
46
  start_server(framework, port)
@@ -54,10 +64,15 @@ module Everywhere
54
64
 
55
65
  private
56
66
 
57
- def requested_targets(target, desktop:, ios:, browser:)
67
+ # --target names ONE shell; the boolean flags are additive. Desktop is the
68
+ # fallback for any --target that isn't a mobile os, which keeps
69
+ # `--target macos-arm64` (and a bare `--target`) meaning what it always did.
70
+ def requested_targets(target, desktop:, ios:, android:, browser:)
71
+ target_os = target && Everywhere::Config.os_of(target)
58
72
  targets = []
59
- targets << "desktop" if desktop || (target && Everywhere::Config.os_of(target) != "ios")
60
- targets << "ios" if ios || (target && Everywhere::Config.os_of(target) == "ios")
73
+ targets << "desktop" if desktop || (target && !%w[ios android].include?(target_os))
74
+ targets << "ios" if ios || target_os == "ios"
75
+ targets << "android" if android || target_os == "android"
61
76
  targets << "browser" if browser
62
77
  targets
63
78
  end
@@ -100,6 +115,7 @@ module Everywhere
100
115
  def launch(target)
101
116
  case target
102
117
  when "ios" then launch_ios
118
+ when "android" then launch_android
103
119
  when "browser" then launch_browser
104
120
  else launch_desktop
105
121
  end
@@ -134,6 +150,26 @@ module Everywhere
134
150
  Simulator.launch(udid, @config.bundle_id(target: "ios-arm64"), env: { "EVERYWHERE_DEV_URL" => @dev_url })
135
151
  end
136
152
 
153
+ # The Android counterpart of launch_ios: build Debug (warm after the first
154
+ # run), put it on a booted emulator and launch it. Unlike the Simulator,
155
+ # which shares the Mac's loopback, an Android device reaches the host
156
+ # through `adb reverse` — so the URL is resolved per-device rather than
157
+ # reused from @dev_url (see Emulator.dev_url for the fallback ladder).
158
+ # Pressing a again rebuilds and relaunches, so it doubles as a reload.
159
+ def launch_android
160
+ serial = Emulator.boot_default!
161
+ url = Emulator.dev_url(@dev_url, serial: serial, port: @port, override: @dev_url_override)
162
+
163
+ app = Builders::Android.new(config: @config, root: @config.root, target: ANDROID_TARGET)
164
+ .build!(dist_dir: File.expand_path("dist", @config.root), dev: true, dev_url: url)
165
+
166
+ application_id = @config.bundle_id(target: ANDROID_TARGET)
167
+ UI.step("installing #{UI.bold(File.basename(app))}")
168
+ Emulator.install(serial, app, application_id: application_id)
169
+ UI.step("launching → #{UI.cyan(url)}")
170
+ Emulator.launch(serial, application_id)
171
+ end
172
+
137
173
  def launch_browser
138
174
  UI.step("opening browser → #{UI.cyan(@dev_url)}")
139
175
  opener = RUBY_PLATFORM.include?("darwin") ? "open" : "xdg-open"
@@ -164,14 +200,16 @@ module Everywhere
164
200
  # -icanon -echo disables only line buffering and echo, leaving output
165
201
  # processing (and Ctrl-C as SIGINT) intact.
166
202
  def interact
167
- UI.note("press #{UI.bold("d")} desktop · #{UI.bold("i")} iOS Simulator · #{UI.bold("b")} browser · " \
168
- "#{UI.bold("l")} iOS logs · #{UI.bold("q")} quit", marker: "⌨", color: :cyan)
203
+ UI.note("press #{UI.bold("d")} desktop · #{UI.bold("i")} iOS Simulator · #{UI.bold("a")} Android · " \
204
+ "#{UI.bold("b")} browser · #{UI.bold("l")} iOS logs · #{UI.bold("q")} quit",
205
+ marker: "⌨", color: :cyan)
169
206
  saved_tty = `stty -g`.chomp
170
207
  system("stty", "-icanon", "-echo")
171
208
  loop do
172
209
  case $stdin.getc
173
210
  when "d" then launch("desktop")
174
211
  when "i" then launch("ios")
212
+ when "a" then launch("android")
175
213
  when "b" then launch("browser")
176
214
  when "l" then toggle_logs
177
215
  when "q", "\u0004", nil then break # q, Ctrl-D, EOF (Ctrl-C stays a real SIGINT)
@@ -3,14 +3,15 @@
3
3
  require "dry/cli"
4
4
  require_relative "../shellout"
5
5
  require_relative "../paths"
6
+ require_relative "../android_sdk"
6
7
 
7
8
  module Everywhere
8
9
  module Commands
9
10
  # Check that this machine can build native apps. Desktop checks always
10
- # run; the iOS section joins in when the app's build.targets include an
11
- # ios target, or explicitly via --target ios-*.
11
+ # run; the iOS and Android sections join in when the app's build.targets
12
+ # include such a target, or explicitly via --target ios-* / android-*.
12
13
  class Doctor < Dry::CLI::Command
13
- desc "Check the toolchain (rbe-tebako, rust, brew deps; Xcode for iOS targets)"
14
+ desc "Check the toolchain (rbe-tebako, rust, brew deps; Xcode for iOS, SDK/JDK for Android)"
14
15
 
15
16
  option :root, default: ".", desc: "App root (its build.targets pick the sections)"
16
17
  option :target, default: nil, desc: "Also check a specific target's toolchain (e.g. ios-arm64)"
@@ -22,6 +23,7 @@ module Everywhere
22
23
  ok &= check("homebrew bison") { !`brew --prefix bison 2>/dev/null`.strip.empty? }
23
24
 
24
25
  ok &= ios_checks if ios_wanted?(root, target)
26
+ ok &= android_checks if android_wanted?(root, target)
25
27
 
26
28
  puts
27
29
  ok ? UI.success("ready to build") : UI.bad("fix the items above (see https://rubyeverywhere.com/docs/diy/requirements)")
@@ -64,6 +66,90 @@ module Everywhere
64
66
  ok
65
67
  end
66
68
 
69
+ def android_wanted?(root, target)
70
+ return true if target && Everywhere::Config.os_of(target) == "android"
71
+
72
+ Everywhere::Config.load(File.expand_path(root)).targets
73
+ .any? { |t| Everywhere::Config.os_of(t) == "android" }
74
+ rescue StandardError
75
+ false
76
+ end
77
+
78
+ # Every check here resolves by location through AndroidSdk, never off
79
+ # PATH: Android Studio installs the SDK, adb, the emulator and a JDK
80
+ # without exporting any of them, so a `which adb` would report a broken
81
+ # machine that in fact builds fine. Nothing auto-installs — SDK components
82
+ # are gigabytes — so each failure carries the exact command to run.
83
+ def android_checks
84
+ puts
85
+ UI.step("Android target")
86
+ sdk = Everywhere::AndroidSdk
87
+ root = sdk.sdk_root
88
+
89
+ ok = fix_check(labelled("Android SDK", root && UI.short_path(root)),
90
+ "install Android Studio (it provisions the SDK, adb, the emulator and a " \
91
+ "JDK), or point ANDROID_HOME at an existing SDK") { !root.nil? }
92
+
93
+ jdk = sdk.java_version
94
+ ok &= fix_check(labelled("JDK #{sdk::MINIMUM_JDK}+", jdk && "found #{jdk}"),
95
+ "install Android Studio (it bundles a JDK) or set JAVA_HOME to a " \
96
+ "JDK #{sdk::MINIMUM_JDK} or newer") { !jdk.nil? && jdk >= sdk::MINIMUM_JDK }
97
+
98
+ # This one has to come before adb, the emulator, the AVD and the
99
+ # platform, because every one of those remediations is an sdkmanager
100
+ # line — and sdkmanager is exactly what can be missing. Phase 0 found a
101
+ # machine with a full SDK, adb, the emulator, build-tools and an AVD but
102
+ # no cmdline-tools anywhere, so "run this sdkmanager command" was the
103
+ # one piece of advice that couldn't be followed. Reported, but NOT
104
+ # folded into `ok`: that machine builds Android apps fine, and failing
105
+ # doctor over a component it doesn't need would be a lie.
106
+ if sdk.cmdline_tools?
107
+ UI.ok(labelled("Android SDK command-line tools", UI.short_path(sdk.sdkmanager)))
108
+ else
109
+ UI.warn("Android SDK command-line tools missing — nothing below can be installed " \
110
+ "until they are: #{sdk.cmdline_tools_hint}")
111
+ end
112
+
113
+ ok &= fix_check(labelled("adb", sdk.adb && UI.short_path(sdk.adb)),
114
+ sdk.install_hint("platform-tools")) { !sdk.adb.nil? }
115
+
116
+ ok &= fix_check(labelled("emulator", sdk.emulator && UI.short_path(sdk.emulator)),
117
+ sdk.install_hint("emulator")) { !sdk.emulator.nil? }
118
+
119
+ avds = sdk.avds
120
+ ok &= fix_check(labelled("an Android virtual device", avds.first),
121
+ sdk.create_avd_hint) { avds.any? }
122
+
123
+ platform = "platforms;android-#{sdk::COMPILE_SDK}"
124
+ ok &= fix_check(labelled(platform, listed(sdk.installed_platforms)),
125
+ sdk.install_hint(platform)) { sdk.platform? }
126
+
127
+ ok &= fix_check("bundled Android shell template",
128
+ "not shipped in this release of ruby_everywhere yet, so Android " \
129
+ "builds aren't available") { !Everywhere::Paths.android_dir.nil? }
130
+ ok
131
+ end
132
+
133
+ # "adb (~/Library/Android/sdk/platform-tools/adb)" — the resolved location
134
+ # matters on Android in a way it doesn't on iOS, because there are several
135
+ # plausible SDK roots and the user can't tell from `which` which one we
136
+ # picked.
137
+ def labelled(label, detail) = detail ? "#{label} #{UI.dim("(#{detail})")}" : label
138
+
139
+ def listed(items) = items.empty? ? "none installed" : "have #{items.join(", ")}"
140
+
141
+ # check() prints one fixed label, but an Android remediation is only
142
+ # knowable after resolution (the sdkmanager path depends on where the SDK
143
+ # turned out to be), so a failure appends the exact command to run.
144
+ def fix_check(label, fix)
145
+ passed = yield
146
+ passed ? UI.ok(label) : UI.bad("#{label} — #{fix}")
147
+ passed
148
+ rescue StandardError
149
+ UI.bad("#{label} — #{fix}")
150
+ false
151
+ end
152
+
67
153
  def check(label)
68
154
  passed = yield
69
155
  passed ? UI.ok(label) : UI.bad(label)
@@ -187,7 +187,7 @@ module Everywhere
187
187
  # # # (`every build` / `every dev --ios`) work today;
188
188
  # # # hosted platform builds for iOS aren't live yet.
189
189
  # # - android-arm64 # future — mobile is remote mode only
190
- # permissions: [notifications, filesystem:read, clipboard]
190
+ # capabilities: [notifications, filesystem:read, clipboard] # desktop OS integration
191
191
 
192
192
  # Per-platform overrides. Keys are platform names (macos / ios / android /
193
193
  # windows / linux); anything here overrides the app: defaults above for that
@@ -1,20 +1,27 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "dry/cli"
4
+ require_relative "../android_sdk"
5
+ require_relative "../config"
6
+ require_relative "../emulator"
4
7
  require_relative "../simulator"
5
8
  require_relative "../ui"
6
9
 
7
10
  module Everywhere
8
11
  module Commands
9
- # Tail the native shell's logs. iOS only so far: the Simulator's unified
10
- # log, scoped to the shell process. Watches (log stream) by default;
11
- # --last replays recent history (log show) and exits.
12
+ # Tail the native shell's logs. Two platforms, one shape: the iOS Simulator's
13
+ # unified log scoped to the shell process, or Android's logcat scoped to the
14
+ # shell's process id. Watches by default; --last replays recent history and
15
+ # exits.
12
16
  class Logs < Dry::CLI::Command
13
- desc "Stream native shell logs (iOS Simulator; watches by default)"
17
+ desc "Stream native shell logs (iOS Simulator or Android; watches by default)"
14
18
 
15
- option :ios, type: :boolean, default: false, desc: "iOS Simulator logs (the only target so far)"
16
- option :last, default: nil, desc: 'Show recent history instead of watching (e.g. "5m", "1h")'
19
+ option :ios, type: :boolean, default: false, desc: "iOS Simulator logs (unified log)"
20
+ option :android, type: :boolean, default: false, desc: "Android logs (logcat, scoped to the app's process)"
21
+ option :last, default: nil, desc: 'Show recent history instead of watching (e.g. "5m", "1h"; Android also takes a line count)'
17
22
  option :udid, default: nil, desc: "Simulator UDID (default: the booted one)"
23
+ option :serial, default: nil, desc: "Android device serial (default: the running emulator)"
24
+ option :app_id, default: nil, desc: "Android applicationId (default: the one in config/everywhere.yml)"
18
25
  option :verbose, type: :boolean, default: false, desc: "Everything the shell process logs, debug level included"
19
26
 
20
27
  # The template contract fixes the product name (App.app), so the shell's
@@ -28,14 +35,56 @@ module Everywhere
28
35
  PREDICATE = 'process == "App" AND eventType == logEvent AND NOT subsystem BEGINSWITH "com.apple"'
29
36
  VERBOSE_PREDICATE = 'process == "App"'
30
37
 
31
- def call(ios: false, last: nil, udid: nil, verbose: false, **)
32
- UI.die!("only the iOS Simulator is supported so far run: every logs --ios") unless ios
38
+ # Android has no predicate language, so the same job is split in two:
39
+ # `--pid=` fixes the process (see #android_logs) and a filterspec fixes
40
+ # the priority floor.
41
+ #
42
+ # Inside our own process the analogue of iOS's Apple-framework chatter is
43
+ # the platform's own: ART's GC bookkeeping, binder transactions,
44
+ # chromium's internal tracing. Those sit at VERBOSE, so the default floor
45
+ # is DEBUG — which keeps Hotwire Native's logging (Hotwire-Core and
46
+ # Hotwire-Navigation both log at debug), keeps every WebView console
47
+ # message (chromium reports them at INFO), and drops the runtime's
48
+ # housekeeping. --verbose lowers the floor to VERBOSE.
49
+ #
50
+ # Deliberately NOT a tag list: filtering on Hotwire-Core/Hotwire-Navigation
51
+ # would be the obvious way to cut noise and would hide the two things a
52
+ # shell developer most wants — their own Log calls and their page's
53
+ # console output. Scoping by process keeps everything that is ours and
54
+ # nothing that isn't, which is the same trade the iOS predicate makes.
55
+ DEFAULT_LEVEL = "*:D"
56
+ VERBOSE_LEVEL = "*:V"
33
57
 
58
+ # `-v time` is the compact-style analogue: with the process already fixed,
59
+ # repeating its pid on every line is noise. --verbose widens to threadtime,
60
+ # which adds the thread id you need once you're reading runtime internals.
61
+ DEFAULT_FORMAT = "time"
62
+ VERBOSE_FORMAT = "threadtime"
63
+
64
+ def call(ios: false, android: false, last: nil, udid: nil, serial: nil, app_id: nil, verbose: false, **)
65
+ UI.die!("pick a platform — run: every logs --ios, or every logs --android") unless ios || android
66
+ UI.die!("--ios and --android are two separate log streams — pick one") if ios && android
67
+
68
+ if android
69
+ android_logs(serial: serial, app_id: app_id, last: last, verbose: verbose)
70
+ else
71
+ ios_logs(udid: udid, last: last, verbose: verbose)
72
+ end
73
+ end
74
+
75
+ # --- iOS ------------------------------------------------------------------
76
+
77
+ def ios_logs(udid: nil, last: nil, verbose: false)
34
78
  udid ||= Everywhere::Simulator.booted_udid
35
79
  UI.die!("no booted iOS Simulator — start one with `every dev --ios`") unless udid
36
80
 
37
81
  UI.step(last ? "iOS Simulator logs from the last #{UI.bold(last)}"
38
82
  : "watching iOS Simulator logs #{UI.dim("(Ctrl-C to stop)")}")
83
+ # exec replaces this process, so anything still sitting in Ruby's stdout
84
+ # buffer dies with it — and stdout IS buffered whenever it's a pipe,
85
+ # which is exactly how `every dev`'s log toggle runs us. Without this
86
+ # the header above silently vanishes in the one context that spawns us.
87
+ $stdout.flush
39
88
  exec(*argv(udid, last: last, verbose: verbose))
40
89
  end
41
90
 
@@ -51,6 +100,66 @@ module Everywhere
51
100
  end
52
101
  argv + ["--style", "compact", "--predicate", verbose ? VERBOSE_PREDICATE : PREDICATE]
53
102
  end
103
+
104
+ # --- Android --------------------------------------------------------------
105
+
106
+ # logcat carries every process on the device, so the pid lookup is the
107
+ # whole filter — and it's also why "the app isn't running" is a distinct,
108
+ # extremely common state that has to be said plainly rather than shown as
109
+ # an empty stream. Unlike iOS, the process name isn't fixed by the
110
+ # template (it's the applicationId), so the config supplies it.
111
+ def android_logs(serial: nil, app_id: nil, last: nil, verbose: false)
112
+ serial ||= Everywhere::Emulator.booted_serial
113
+ UI.die!("no Android emulator or device — start one with `every dev --android`") unless serial
114
+
115
+ application_id = app_id || Everywhere::Config.load(Dir.pwd).bundle_id(target: "android-arm64")
116
+ pid = Everywhere::Emulator.pid_of(serial, application_id)
117
+ unless pid
118
+ UI.die!("#{application_id} isn't running on #{serial} — start it with `every dev --android` " \
119
+ "(or pass --app-id if that isn't this app's applicationId)")
120
+ end
121
+
122
+ UI.step(last ? "Android logs from the last #{UI.bold(window_label(last))}"
123
+ : "watching Android logs for #{UI.bold(application_id)} #{UI.dim("(Ctrl-C to stop)")}")
124
+ $stdout.flush # see ios_logs — exec discards a buffered header
125
+ exec(*android_argv(serial, pid, last: last, verbose: verbose))
126
+ end
127
+
128
+ # `-d` dumps what's buffered and exits — the `log show` half of the iOS
129
+ # split — and `-t` bounds that dump. Without `-d`, logcat follows.
130
+ def android_argv(serial, pid, last: nil, verbose: false)
131
+ argv = [Everywhere::AndroidSdk.adb!, "-s", serial, "logcat", "--pid=#{pid}"]
132
+ argv += ["-d", "-t", logcat_window(last)] if last
133
+ argv + ["-v", verbose ? VERBOSE_FORMAT : DEFAULT_FORMAT,
134
+ verbose ? VERBOSE_LEVEL : DEFAULT_LEVEL]
135
+ end
136
+
137
+ # logcat's -t takes a line count or a timestamp; it has no notion of "5m".
138
+ # So a bare number passes straight through as a count, and a duration in
139
+ # the iOS --last spelling is converted to the timestamp logcat wants.
140
+ #
141
+ # That timestamp is compared against the *device's* clock. An emulator
142
+ # inherits the host's clock and timezone so it lines up exactly; a
143
+ # physical device whose clock has drifted will return a little more or
144
+ # less than asked. A line count has no such ambiguity, which is why both
145
+ # spellings stay.
146
+ LOGCAT_TIME = "%m-%d %H:%M:%S.000"
147
+ DURATION_UNITS = { "s" => 1, "m" => 60, "h" => 3600, "d" => 86_400 }.freeze
148
+
149
+ def logcat_window(last)
150
+ text = last.to_s.strip
151
+ return text if text.match?(/\A\d+\z/)
152
+
153
+ if (match = text.match(/\A(\d+)([smhd])\z/i))
154
+ seconds = match[1].to_i * DURATION_UNITS.fetch(match[2].downcase)
155
+ return (Time.now - seconds).strftime(LOGCAT_TIME)
156
+ end
157
+
158
+ UI.die!(%(--last takes a line count ("200") or a duration ("30s", "5m", "2h") — got #{last.inspect}))
159
+ end
160
+
161
+ # "200" reads as a count, everything else as a duration.
162
+ def window_label(last) = last.to_s.match?(/\A\d+\z/) ? "#{last} lines" : last.to_s
54
163
  end
55
164
  end
56
165
  end