ruby_everywhere 0.4.0 → 0.5.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 (51) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +86 -2
  3. data/bridge/README.md +70 -1
  4. data/bridge/everywhere/bridge.js +151 -0
  5. data/bridge/everywhere/native.css +61 -0
  6. data/lib/everywhere/auth_handoff.rb +8 -2
  7. data/lib/everywhere/builders/desktop.rb +326 -0
  8. data/lib/everywhere/child_processes.rb +74 -0
  9. data/lib/everywhere/commands/build.rb +44 -5
  10. data/lib/everywhere/commands/dev.rb +426 -59
  11. data/lib/everywhere/commands/install.rb +20 -0
  12. data/lib/everywhere/commands/release.rb +2 -2
  13. data/lib/everywhere/config.rb +298 -4
  14. data/lib/everywhere/console.rb +117 -0
  15. data/lib/everywhere/desktop_assets.rb +150 -0
  16. data/lib/everywhere/dock/footer.rb +150 -0
  17. data/lib/everywhere/dock/screen.rb +114 -0
  18. data/lib/everywhere/dock/state.rb +59 -0
  19. data/lib/everywhere/dock.rb +238 -0
  20. data/lib/everywhere/emulator.rb +2 -2
  21. data/lib/everywhere/fatal.rb +20 -0
  22. data/lib/everywhere/line_pump.rb +89 -0
  23. data/lib/everywhere/log_filter.rb +37 -0
  24. data/lib/everywhere/native_helper.rb +38 -5
  25. data/lib/everywhere/paths.rb +62 -11
  26. data/lib/everywhere/relay.rb +77 -0
  27. data/lib/everywhere/shellout.rb +90 -15
  28. data/lib/everywhere/task_pool.rb +123 -0
  29. data/lib/everywhere/ui.rb +54 -11
  30. data/lib/everywhere/version.rb +1 -1
  31. data/support/desktop/README.md +121 -0
  32. data/support/{shell → desktop}/src-tauri/Cargo.lock +23 -0
  33. data/support/{shell → desktop}/src-tauri/Cargo.toml +19 -1
  34. data/support/{shell → desktop}/src-tauri/capabilities/default.json +7 -0
  35. data/support/desktop/src-tauri/gen/schemas/capabilities.json +1 -0
  36. data/support/desktop/src-tauri/src/extension_host.rs +53 -0
  37. data/support/desktop/src-tauri/src/extensions/mod.rs +39 -0
  38. data/support/{shell → desktop}/src-tauri/src/main.rs +355 -10
  39. data/support/{shell → desktop}/src-tauri/tauri.conf.json +2 -2
  40. data/support/{macos → release/macos}/notarize.sh +2 -2
  41. metadata +31 -17
  42. data/support/github/build.yml +0 -85
  43. data/support/shell/src-tauri/gen/schemas/capabilities.json +0 -1
  44. /data/support/{shell → desktop}/splash/index.html +0 -0
  45. /data/support/{shell → desktop}/src-tauri/build.rs +0 -0
  46. /data/support/{shell → desktop}/src-tauri/gen/schemas/acl-manifests.json +0 -0
  47. /data/support/{shell → desktop}/src-tauri/gen/schemas/desktop-schema.json +0 -0
  48. /data/support/{shell → desktop}/src-tauri/gen/schemas/macOS-schema.json +0 -0
  49. /data/support/{shell → desktop}/src-tauri/icons/icon.png +0 -0
  50. /data/support/{shell → desktop}/src-tauri/src/updater.rs +0 -0
  51. /data/support/{macos → release/macos}/entitlements.plist +0 -0
@@ -158,6 +158,17 @@ module Everywhere
158
158
  # - label: Quit
159
159
  # action: quit
160
160
 
161
+ # Desktop window chrome. title_bar: overlay floats the macOS traffic
162
+ # lights over your page with no title text (reserve the space with the
163
+ # .everywhere-titlebar-inset class from everywhere/native.css);
164
+ # frameless removes the system chrome entirely, and the page draws its
165
+ # own title bar and calls Everywhere.window.* for the controls.
166
+ # window:
167
+ # title_bar: overlay # decorated (default) | overlay | frameless
168
+ # title: false # draw the title text
169
+ # size: [1100, 750]
170
+ # min_size: [600, 400]
171
+
161
172
  # Mobile tab bar (iOS today, Android soon). Served live from
162
173
  # /everywhere/ios_v1.json too, so tab changes deploy with the app —
163
174
  # no app-store release. Icons: ios = SF Symbol name.
@@ -177,6 +188,15 @@ module Everywhere
177
188
  # ios:
178
189
  # lazy_load_tabs: true # defer each tab's first load until it's tapped
179
190
  # # (false = load every tab up front; omit = default)
191
+ #
192
+ # Rust you keep in native/desktop/ and compile into the desktop
193
+ # shell. Declaring anything here switches the desktop build to a
194
+ # per-app cargo compile — the first one takes minutes.
195
+ # desktop:
196
+ # commands: [scan_ports] # reachable as Everywhere.desktop.invoke("scan_ports")
197
+ # setup: true # run native/desktop/setup.rs at boot
198
+ # crates:
199
+ # serialport: "4.3"
180
200
 
181
201
  # Durable build knobs (CLI flags still override per-run).
182
202
  # build:
@@ -12,7 +12,7 @@ module Everywhere
12
12
  # Produce a signed, notarized, stapled, distributable artifact + release.json.
13
13
  #
14
14
  # `every release` wraps the existing signing engine
15
- # (cli/support/macos/notarize.sh) rather than reimplementing
15
+ # (cli/support/release/macos/notarize.sh) rather than reimplementing
16
16
  # codesign/notarytool, and emits the machine-readable receipt every build
17
17
  # surface (local, CI, Platform) must produce. See
18
18
  # platform/docs/build-engine.md §4, §7, §9.
@@ -20,7 +20,7 @@ module Everywhere
20
20
  # Today only the macos builder is live; the target is parameterized so
21
21
  # ios/android/windows/linux slot in as builders later.
22
22
  class Release < Dry::CLI::Command
23
- NOTARIZE = File.expand_path("../../../support/macos/notarize.sh", __dir__)
23
+ NOTARIZE = File.expand_path("../../../support/release/macos/notarize.sh", __dir__)
24
24
 
25
25
  desc "Sign, notarize, staple, and emit release.json for a distributable build"
26
26
 
@@ -92,18 +92,45 @@ module Everywhere
92
92
  HTML
93
93
  end
94
94
 
95
- # :ios, :android or nil, from a User-Agent string. The shells prepend
96
- # "RubyEverywhere/<version> (<os>)" to Hotwire Native's own marker; this
97
- # reads the marker, which is present from the very first request. Shared by
98
- # the view helpers and the auth middleware.
95
+ # The desktop shell's marker. WKWebView APPENDS
96
+ # `applicationNameForUserAgent` to the real system UA, so this arrives
97
+ # alongside a normal Safari UA rather than replacing it hence a pattern
98
+ # rather than a prefix check. The version is absent when everywhere.yml
99
+ # declares none.
100
+ #
101
+ # macOS only for now: the marker is set in macos_webview_configuration, and
102
+ # Windows/Linux have no append-only equivalent (their user_agent() REPLACES
103
+ # the string, and fabricating a whole UA is worse than not marking it).
104
+ # Desktop only ships macOS today; revisit when it doesn't.
105
+ DESKTOP_UA = %r{\bRubyEverywhere(?:/[\w.\-]+)? \((?:macos|windows|linux)\)}
106
+
107
+ MOBILE_PLATFORMS = %i[ios android].freeze
108
+
109
+ # :ios, :android, :desktop or nil, from a User-Agent string. Present from
110
+ # the very first request, before any JS has run. Shared by the view helpers
111
+ # and the auth middleware.
112
+ #
113
+ # Mobile is checked FIRST and the order matters: the mobile shells prepend
114
+ # the same "RubyEverywhere/<version> (<os>)" marker to Hotwire Native's own,
115
+ # so a mobile UA matches DESKTOP_UA too.
99
116
  def native_platform_of(user_agent)
100
117
  ua = user_agent.to_s
101
118
  return :ios if ua.include?("Hotwire Native iOS")
102
119
  return :android if ua.include?("Hotwire Native Android")
120
+ return :desktop if ua.match?(DESKTOP_UA)
103
121
 
104
122
  nil
105
123
  end
106
124
 
125
+ # Just the phone shells. Anything that leans on a mobile-only affordance —
126
+ # the OAuth handoff to ASWebAuthenticationSession / Custom Tabs, biometric
127
+ # gating — has to ask for this rather than "is this a native shell", because
128
+ # the desktop shell answers yes to that and has neither.
129
+ def mobile_platform_of(user_agent)
130
+ platform = native_platform_of(user_agent)
131
+ platform if MOBILE_PLATFORMS.include?(platform)
132
+ end
133
+
107
134
  # Test/reset hook.
108
135
  attr_writer :tabs_filter
109
136
  end
@@ -796,6 +823,115 @@ module Everywhere
796
823
  "one of: #{ANDROID_ICON_FONTS.keys.join(", ")}"]
797
824
  end
798
825
 
826
+ # The desktop half of "supernative": Rust the app repo carries in
827
+ # native/desktop/ that `every build` compiles into the Tauri shell.
828
+ #
829
+ # The shape differs from ios/android because desktop differs: the whole UI
830
+ # is a webview, so there are no native screens or splash views to register.
831
+ # What an app actually wants down here is the machine — a serial port, a USB
832
+ # device, an FFI library, a Tauri plugin — reached from the page.
833
+ #
834
+ # native:
835
+ # desktop:
836
+ # commands: [scan_ports] # fns reachable from the page
837
+ # setup: true # run native/desktop/setup.rs at boot
838
+ # crates:
839
+ # serialport: "4.3"
840
+ # reqwest: { version: "0.12", features: [json] }
841
+ #
842
+ # A command is a plain function, NOT a #[tauri::command] — Tauri's typed
843
+ # commands only arrive through invoke, and invoke is unavailable to our
844
+ # pages (they're served over http; see extension_host.rs in the shell). The
845
+ # signature is uniform:
846
+ #
847
+ # pub fn scan_ports(app: &tauri::AppHandle, payload: serde_json::Value)
848
+ # -> Result<serde_json::Value, String>
849
+ #
850
+ # Declaring anything here has a real cost the mobile sections don't have:
851
+ # the shell stops being one prebuilt crate shared by every app and gets
852
+ # stamped and compiled per app (see Builders::Desktop). Apps that declare
853
+ # nothing keep the fast path.
854
+ def native_desktop
855
+ section = @data.dig("native", "desktop")
856
+ section.is_a?(Hash) ? section : {}
857
+ end
858
+
859
+ def native_desktop_commands = Array(native_desktop["commands"]).map { |name| name.to_s.strip }
860
+
861
+ def native_desktop_setup? = native_desktop["setup"] == true
862
+
863
+ # Cargo dependencies, normalized to { "name" => {...table} } so the
864
+ # generator has one shape to write. The short form (a bare version string)
865
+ # becomes { "version" => "4.3" }.
866
+ def native_desktop_crates
867
+ entries = native_desktop["crates"]
868
+ return {} unless entries.is_a?(Hash)
869
+
870
+ entries.to_h do |name, spec|
871
+ table = spec.is_a?(Hash) ? spec : { "version" => spec.to_s }
872
+ [name.to_s.strip, table]
873
+ end
874
+ end
875
+
876
+ def native_desktop? = !(native_desktop_commands.empty? && native_desktop_crates.empty?) ||
877
+ native_desktop_setup?
878
+
879
+ # Everything here is interpolated into generated Rust and TOML, so names are
880
+ # validated hard — same reasoning as MAVEN_COORDINATE above. A command name
881
+ # becomes a path segment in a generate_handler! list; a crate name and its
882
+ # features become bare TOML keys and quoted strings.
883
+ RUST_FN_NAME = /\A[a-z_][a-z0-9_]*\z/
884
+ CRATE_NAME = /\A[a-zA-Z0-9_][a-zA-Z0-9_-]*\z/
885
+ # Cargo's requirement syntax, loosely: ^1.2, ~1.2, >=1.0, 1.*, 1.2.3-beta.1.
886
+ # Deliberately no spaces and no commas — a multi-requirement string would
887
+ # need quoting rules we don't want to own, and cargo reports anything
888
+ # subtler at build time.
889
+ CRATE_VERSION = /\A[\^~<>=]{0,2}\d+(\.\d+){0,2}(\.\*)?(-[0-9A-Za-z.-]+)?\z/
890
+ CRATE_FEATURE = /\A[A-Za-z0-9_][A-Za-z0-9_+-]*\z/
891
+ # Where a git dependency may point. Same rule the SPM packages use.
892
+ CRATE_GIT_URL = PACKAGE_URL
893
+
894
+ def native_desktop_errors
895
+ raw = @data.dig("native", "desktop")
896
+ return [] if raw.nil?
897
+ return ["native.desktop must be a mapping"] unless raw.is_a?(Hash)
898
+
899
+ errors = []
900
+ commands = raw["commands"]
901
+ if !commands.nil? && !commands.is_a?(Array)
902
+ errors << "native.desktop.commands must be a list of function names"
903
+ else
904
+ errors += native_desktop_commands.reject { |name| name.match?(RUST_FN_NAME) }.map do |name|
905
+ "native.desktop.commands: #{name.inspect} is not a Rust function name — " \
906
+ "snake_case (letters, digits, _), matching the fn in native/desktop/"
907
+ end
908
+ duplicates = native_desktop_commands.tally.select { |_name, count| count > 1 }.keys
909
+ errors += duplicates.map do |name|
910
+ "native.desktop.commands: #{name.inspect} is listed twice"
911
+ end
912
+ end
913
+
914
+ setup = raw["setup"]
915
+ errors << "native.desktop.setup must be true or false" unless setup.nil? || boolean_or_nil(setup) == setup
916
+
917
+ errors + native_desktop_crate_errors
918
+ end
919
+
920
+ def native_desktop_crate_errors
921
+ raw = native_desktop["crates"]
922
+ return [] if raw.nil?
923
+ return ["native.desktop.crates must be a mapping of crate name to version"] unless raw.is_a?(Hash)
924
+
925
+ raw.flat_map do |name, spec|
926
+ at = "native.desktop.crates.#{name}"
927
+ errors = []
928
+ unless name.to_s.strip.match?(CRATE_NAME)
929
+ errors << "#{at}: #{name.to_s.inspect} is not a crate name (letters, digits, _, -)"
930
+ end
931
+ errors + crate_spec_errors(at, spec)
932
+ end
933
+ end
934
+
799
935
  # Third-party sign-in (Sign in with Apple / Google / GitHub / anything
800
936
  # OmniAuth speaks). Providers refuse to run — or run badly — inside an app's
801
937
  # web view, so the mobile shell hands these paths to an
@@ -931,6 +1067,99 @@ module Everywhere
931
1067
  end
932
1068
  end
933
1069
 
1070
+ # Desktop window chrome. Desktop-only: on mobile the OS owns the frame, so
1071
+ # mobile builds ignore this section entirely.
1072
+ #
1073
+ # window:
1074
+ # title_bar: overlay # decorated (default) | overlay | frameless
1075
+ # title: false # draw the title text (default true)
1076
+ # size: [1100, 750] # initial inner size
1077
+ # min_size: [600, 400]
1078
+ # resizable: true
1079
+ # drag_height: 28 # top strip that drags the window; 0 turns it off
1080
+ #
1081
+ # Every reader returns nil when the key is absent so `to_shell_hash` drops it
1082
+ # and the shell keeps its own default — there is exactly one place each
1083
+ # default lives, and it's the Rust side.
1084
+ #
1085
+ # `overlay` is the macOS "traffic lights floating over your content" look
1086
+ # (TitleBarStyle::Overlay + hidden title). Windows and Linux have no such
1087
+ # style, so the shell falls back to frameless there and the page draws its
1088
+ # own controls via Everywhere.window.
1089
+ WINDOW_TITLE_BARS = %w[decorated overlay frameless].freeze
1090
+
1091
+ def window
1092
+ section = @data["window"]
1093
+ section.is_a?(Hash) ? section : {}
1094
+ end
1095
+
1096
+ def window_title_bar
1097
+ value = window["title_bar"].to_s
1098
+ value if WINDOW_TITLE_BARS.include?(value)
1099
+ end
1100
+
1101
+ # Tri-state, like native_ios_lazy_load_tabs: an absent key leaves the shell
1102
+ # on its default rather than forcing one.
1103
+ def window_title = boolean_or_nil(window["title"])
1104
+ def window_resizable = boolean_or_nil(window["resizable"])
1105
+
1106
+ def window_size = window_dimensions("size")
1107
+ def window_min_size = window_dimensions("min_size")
1108
+
1109
+ # How much of the top of the page drags the window, in CSS pixels. Only
1110
+ # meaningful for overlay/frameless, where there's no system title bar left
1111
+ # to grab. nil leaves the shell's 28px default (the height of the macOS
1112
+ # traffic-light band); 0 turns dragging off for an app that wants to place
1113
+ # its own drag regions.
1114
+ def window_drag_height
1115
+ value = window["drag_height"]
1116
+ value.to_f if value.is_a?(Numeric) && !value.negative?
1117
+ end
1118
+
1119
+ # The window subset the shell needs. nil (absent from everywhere.json) when
1120
+ # the app declares nothing, so the shell's own defaults stand untouched.
1121
+ def window_shell_hash
1122
+ hash = {
1123
+ "title_bar" => window_title_bar,
1124
+ "title" => window_title,
1125
+ "resizable" => window_resizable,
1126
+ "size" => window_size,
1127
+ "min_size" => window_min_size,
1128
+ "drag_height" => window_drag_height
1129
+ }.compact
1130
+ hash unless hash.empty?
1131
+ end
1132
+
1133
+ def window_errors
1134
+ raw = @data["window"]
1135
+ return [] if raw.nil?
1136
+ return ["window: must be a mapping"] unless raw.is_a?(Hash)
1137
+
1138
+ errors = []
1139
+ bar = raw["title_bar"]
1140
+ if bar && !WINDOW_TITLE_BARS.include?(bar.to_s)
1141
+ errors << "window.title_bar #{bar.to_s.inspect} must be one of " \
1142
+ "#{WINDOW_TITLE_BARS.join(" / ")}"
1143
+ end
1144
+
1145
+ errors += %w[size min_size].filter_map do |key|
1146
+ next if raw[key].nil? || window_dimensions(key)
1147
+
1148
+ "window.#{key} must be two positive numbers, like [1100, 750]"
1149
+ end
1150
+
1151
+ drag = raw["drag_height"]
1152
+ unless drag.nil? || (drag.is_a?(Numeric) && !drag.negative?)
1153
+ errors << "window.drag_height must be a number of pixels (0 turns dragging off)"
1154
+ end
1155
+
1156
+ errors + %w[title resizable].filter_map do |key|
1157
+ next if raw[key].nil? || boolean_or_nil(raw[key]) == raw[key]
1158
+
1159
+ "window.#{key} must be true or false"
1160
+ end
1161
+ end
1162
+
934
1163
  # Deep linking / universal links. Declares the app's association with its
935
1164
  # web domains so the OS can hand matching URLs to the app instead of the
936
1165
  # browser. The gem serves the two well-known association files this needs —
@@ -1064,6 +1293,7 @@ module Everywhere
1064
1293
  "background_color" => background_color,
1065
1294
  "menu" => (menu unless menu.empty?),
1066
1295
  "tray" => (tray unless tray.empty?),
1296
+ "window" => window_shell_hash,
1067
1297
  "lazy_load_tabs" => native_lazy_load_tabs(target: target),
1068
1298
  "splash_min_seconds" => native_splash_min_seconds(target: target),
1069
1299
  "updates" => updates_shell_hash,
@@ -1085,6 +1315,70 @@ module Everywhere
1085
1315
  def app = @data.fetch("app", nil) || {}
1086
1316
  def appearance = @data.fetch("appearance", nil) || {}
1087
1317
 
1318
+ # One crate's dependency spec: either a bare version string or a table.
1319
+ def crate_spec_errors(at, spec)
1320
+ return version_errors(at, spec) unless spec.is_a?(Hash)
1321
+
1322
+ errors = []
1323
+ # A crate needs somewhere to come from. version: is the common case; git:
1324
+ # and path: are the two escape hatches cargo itself offers.
1325
+ unless %w[version git path].any? { |key| !spec[key].to_s.strip.empty? }
1326
+ errors << "#{at} needs a version: (or git: / path:)"
1327
+ end
1328
+ errors += version_errors("#{at}.version", spec["version"]) if spec.key?("version")
1329
+
1330
+ git = spec["git"].to_s.strip
1331
+ unless git.empty? || git.match?(CRATE_GIT_URL)
1332
+ errors << "#{at}.git #{git.inspect} must be an https:// or git@ URL"
1333
+ end
1334
+
1335
+ # branch/tag/rev/path have no useful shape to check, but they DO end up
1336
+ # inside quoted TOML strings — so the one thing that matters is that they
1337
+ # can't close the quote or start a new line.
1338
+ errors += %w[branch tag rev path].filter_map do |key|
1339
+ value = spec[key].to_s
1340
+ next if value.empty? || !value.match?(/["\\\n\r]/)
1341
+
1342
+ "#{at}.#{key} can't contain quotes, backslashes or newlines"
1343
+ end
1344
+
1345
+ features = spec["features"]
1346
+ if !features.nil? && !features.is_a?(Array)
1347
+ errors << "#{at}.features must be a list"
1348
+ else
1349
+ errors += Array(features).reject { |f| f.to_s.match?(CRATE_FEATURE) }.map do |feature|
1350
+ "#{at}.features: #{feature.to_s.inspect} is not a feature name (letters, digits, _, +, -)"
1351
+ end
1352
+ end
1353
+ errors
1354
+ end
1355
+
1356
+ def version_errors(at, value)
1357
+ version = value.to_s.strip
1358
+ return [] if version.empty?
1359
+ return [] if version.match?(CRATE_VERSION)
1360
+
1361
+ ["#{at} #{version.inspect} is not a cargo version requirement — like \"1.2\", \"^1.2.3\" or \"~0.4\""]
1362
+ end
1363
+
1364
+ # YAML's `yes`/`on` already parse as true, so anything that reaches here
1365
+ # still not a boolean is a genuine mistake rather than a spelling of one.
1366
+ # Deliberately not an endless def: `def f(v) = v if cond` parses as
1367
+ # `(def f(v) = v) if cond`, which would define nothing when cond is false.
1368
+ def boolean_or_nil(value)
1369
+ value if value == true || value == false
1370
+ end
1371
+
1372
+ # A [width, height] pair. Rejects the near-misses (one number, three, a
1373
+ # negative) rather than silently building a 0-pixel window.
1374
+ def window_dimensions(key)
1375
+ value = window[key]
1376
+ return unless value.is_a?(Array) && value.length == 2
1377
+ return unless value.all? { |n| n.is_a?(Numeric) && n.positive? }
1378
+
1379
+ value.map(&:to_f)
1380
+ end
1381
+
1088
1382
  def android_target?(target) = self.class.os_of(target) == "android"
1089
1383
 
1090
1384
  # Per-platform overrides, keyed by os (macos / ios / android / windows /
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "monitor"
4
+
5
+ module Everywhere
6
+ # The one place anything writes to the terminal.
7
+ #
8
+ # `every dev` runs the iOS and Android builds concurrently, each streaming
9
+ # xcodebuild/Gradle output, while the dev server and desktop shell relay their
10
+ # own output and the main thread prints its own chrome. Without a lock those
11
+ # writes tear into each other mid-line. Everything goes through here, and when
12
+ # more than one task is producing output each line is prefixed with its source
13
+ # so you can tell who said what.
14
+ #
15
+ # A Dock can install itself as the sink (Console.dock =); it then gets the
16
+ # chance to erase and repaint its pinned footer around each write. With no
17
+ # dock — every other command, CI, a pipe — writes go straight to $stdout and
18
+ # the output is byte-identical to what it always was.
19
+ #
20
+ # Three rules this file exists to enforce:
21
+ #
22
+ # * $stdout/$stderr are resolved at WRITE time, never memoized. Minitest's
23
+ # capture_io swaps the globals, and a good chunk of the suite asserts on
24
+ # what it captured.
25
+ # * The lock is a Monitor, not a Mutex. Monitor is reentrant, so a UI helper
26
+ # or LogFilter that ends up writing from inside a write can't deadlock.
27
+ # * Nothing calls Console from a Signal.trap handler. Taking a held lock in
28
+ # trap context raises ThreadError. Traps set a flag; the main loop draws.
29
+ module Console
30
+ LOCK = Monitor.new
31
+
32
+ # Short, fixed-width source tags. Colored so a glance separates the streams;
33
+ # padded so the "│" gutter lines up regardless of which task is talking.
34
+ TAGS = {
35
+ web: ["web", :cyan],
36
+ desktop: ["desktop", :magenta],
37
+ ios: ["ios", :blue],
38
+ android: ["android", :green],
39
+ logs: ["logs", :gray]
40
+ }.freeze
41
+
42
+ TAG_WIDTH = TAGS.values.map { |label, _| label.length }.max
43
+
44
+ module_function
45
+
46
+ def synchronize(&block) = LOCK.synchronize(&block)
47
+
48
+ # The installed Dock, or nil for the plain path. Never assume it is set —
49
+ # every command other than `every dev` runs without one.
50
+ def dock = LOCK.synchronize { @dock }
51
+
52
+ def dock=(value)
53
+ LOCK.synchronize { @dock = value }
54
+ end
55
+
56
+ def write(str)
57
+ LOCK.synchronize do
58
+ text = tagged(str)
59
+ next @dock.emit(text) if @dock
60
+
61
+ io = $stdout
62
+ io.write(text)
63
+ io.flush
64
+ end
65
+ end
66
+
67
+ def print(str) = write(str)
68
+ def puts(str) = write("#{str}\n")
69
+
70
+ # Only UI.die! goes to stderr — UI.warn and friends have always been on
71
+ # stdout and the suite asserts them there. It bypasses the dock: an error is
72
+ # the one thing that must survive even if the dock is wedged.
73
+ def error(str)
74
+ LOCK.synchronize do
75
+ @dock&.clear
76
+ io = $stderr
77
+ io.write(tagged("#{str}\n"))
78
+ io.flush
79
+ end
80
+ end
81
+
82
+ # --- per-thread source tagging -------------------------------------------
83
+
84
+ # thread_variable_set (thread-scoped), not Thread#[] (fiber-local): tags are
85
+ # set by TaskPool in one place and read by the relay threads it owns.
86
+ def tag=(name)
87
+ Thread.current.thread_variable_set(:everywhere_console_tag, name&.to_sym)
88
+ end
89
+
90
+
91
+ def tag = Thread.current.thread_variable_get(:everywhere_console_tag)
92
+
93
+ # Tagging switches on only while two or more sources are live, so a single
94
+ # `every dev --ios` (and every existing test that asserts on stdout) sees
95
+ # byte-identical output to before.
96
+ def multiplexed=(bool)
97
+ LOCK.synchronize { @multiplexed = bool }
98
+ end
99
+
100
+ def multiplexed? = LOCK.synchronize { !!@multiplexed }
101
+
102
+ def tagged(str)
103
+ return str unless @multiplexed
104
+
105
+ prefix = prefix_for(tag) or return str
106
+ # Prefix every non-empty line, including inside a multi-line chunk.
107
+ str.gsub(/^(?=.)/) { prefix }
108
+ end
109
+
110
+ def prefix_for(name)
111
+ label, color = TAGS[name]
112
+ return nil unless label
113
+
114
+ "#{UI.public_send(color, label.ljust(TAG_WIDTH))} #{UI.gray("│")} "
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Everywhere
6
+ # Compiles an app's native/desktop/assets/ tree into the flat assets/ folder
7
+ # the desktop shell reads at runtime — Resources/assets/ inside a packaged
8
+ # .app, or the source tree itself under `every dev` (NATIVE_ASSETS_DIR).
9
+ #
10
+ # Unlike iOS and Android there is no platform resource format to generate:
11
+ # macOS has no catalog for a Tauri bundle's loose resources, so "compiling"
12
+ # here means validating the names and copying. The filenames stay exactly as
13
+ # written, because the shell resolves variants itself (see assets_dir /
14
+ # asset_path in the shell's main.rs) — it is the one consumer, and it can read
15
+ # a suffix as cheaply as we can rewrite one.
16
+ #
17
+ # Filenames are the whole API, matching AssetCatalog: <name>[@2x|@3x][~dark].<ext>.
18
+ # Sub-directories organise the tree but do NOT namespace it — names are global,
19
+ # so assets/branding/logo.png is still "logo". That also means the destination
20
+ # can be flat, which is what keeps the Rust side a plain directory read.
21
+ #
22
+ # These assets are for the SHELL, not for your pages: window and tray icons,
23
+ # splash artwork, and anything extension Rust loads. Images your HTML
24
+ # references should keep coming from the asset pipeline, which already
25
+ # fingerprints and serves them.
26
+ module DesktopAssets
27
+ # What the shell can actually decode. Deliberately narrower than
28
+ # AssetCatalog's: these are handed to tauri::image::Image or read by
29
+ # extension Rust, and neither speaks PDF or SVG.
30
+ RASTER = %w[.png .jpg .jpeg].freeze
31
+ # Windows tray icons are conventionally .ico; it's passed through untouched
32
+ # for an extension that wants to hand one to the platform directly.
33
+ PASSTHROUGH = %w[.ico].freeze
34
+ EXTENSIONS = (RASTER + PASSTHROUGH).freeze
35
+ VECTOR = %w[.pdf .svg].freeze
36
+
37
+ # Names the shell gives meaning to on its own. Not reserved — providing one
38
+ # is exactly how you override a default — but worth naming here so the list
39
+ # has one home.
40
+ #
41
+ # tray the menu-bar / system-tray icon, drawn as-is
42
+ # tray-template same, but declared a macOS template image: black + alpha,
43
+ # recoloured by the system so it stays legible in a light
44
+ # menu bar, a dark one, and under a highlight. Wins over
45
+ # `tray` when both are present.
46
+ SHELL_NAMES = %w[tray tray-template].freeze
47
+
48
+ VALID_NAME = /\A[A-Za-z0-9_][A-Za-z0-9_.-]*\z/
49
+
50
+ class << self
51
+ # Copy every image under `source` into `dest`. Returns [names, errors];
52
+ # nothing is written when errors is non-empty, so a broken assets/ tree
53
+ # can't half-stamp a bundle.
54
+ #
55
+ # `dest` is regenerated wholesale — this module owns that directory, and
56
+ # an asset deleted from the app repo has to stop shipping.
57
+ def write!(source, dest)
58
+ files, errors = plan(source)
59
+ return [[], errors] unless errors.empty?
60
+
61
+ FileUtils.rm_rf(dest)
62
+ return [[], []] if files.empty?
63
+
64
+ FileUtils.mkdir_p(dest)
65
+ files.each { |file| FileUtils.cp(file[:path], File.join(dest, File.basename(file[:path]))) }
66
+
67
+ [files.map { |file| file[:name] }.uniq.sort, []]
68
+ end
69
+
70
+ private
71
+
72
+ # Errors are collected rather than raised so one `every build` reports
73
+ # every problem in the tree, not just the first.
74
+ def plan(source)
75
+ return [[], []] unless File.directory?(source)
76
+
77
+ errors = []
78
+ found = Dir.glob(File.join(source, "**", "*"))
79
+ .select { |path| File.file?(path) && !File.basename(path).start_with?(".") }
80
+ .sort
81
+ images, other = found.partition { |path| EXTENSIONS.include?(File.extname(path).downcase) }
82
+
83
+ other.each { |path| errors << unsupported_error(path, source) }
84
+ files = images.filter_map { |path| variant(path, source, errors) }
85
+
86
+ files.group_by { |file| file[:name] }.each do |name, group|
87
+ dirs = group.map { |file| file[:dir] }.uniq
88
+ if dirs.length > 1
89
+ errors << "#{name}: same asset name in #{dirs.map { |dir| label(dir) }.join(" and ")} — " \
90
+ "names are global (the shell reads one flat folder), so rename one"
91
+ end
92
+ group.group_by { |file| [file[:scale], file[:dark]] }.each_value do |clash|
93
+ next if clash.length < 2
94
+
95
+ errors << "#{name}: #{clash.map { |file| file[:relative] }.join(" and ")} both claim the same slot"
96
+ end
97
+ end
98
+
99
+ [files, errors]
100
+ end
101
+
102
+ # A vector gets its own message: "unsupported image type" reads like the
103
+ # file is broken, when the real answer is a one-line export.
104
+ def unsupported_error(path, source)
105
+ relative = relative(path, source)
106
+ if VECTOR.include?(File.extname(path).downcase)
107
+ "#{relative}: the desktop shell can't rasterize vectors — export a PNG " \
108
+ "(at @2x, so it stays sharp on a Retina display)"
109
+ else
110
+ "#{relative}: unsupported image type — use #{EXTENSIONS.join(", ")}"
111
+ end
112
+ end
113
+
114
+ def variant(path, source, errors)
115
+ relative = relative(path, source)
116
+ name, scale, dark = parse(File.basename(path, File.extname(path)))
117
+
118
+ unless name.match?(VALID_NAME)
119
+ errors << "#{relative}: #{name.inspect} isn't a usable asset name — use letters, digits, _, - and ."
120
+ return nil
121
+ end
122
+
123
+ { name: name, dir: File.dirname(relative), scale: scale, dark: dark, path: path, relative: relative }
124
+ end
125
+
126
+ # Trailing @2x/@3x and ~dark markers, in either order, off the stem.
127
+ # Identical to AssetCatalog#parse on purpose: one filename convention
128
+ # across all three platforms is the whole point of the suffix API.
129
+ def parse(base)
130
+ name = base.dup
131
+ scale = nil
132
+ dark = false
133
+ loop do
134
+ if name.sub!(/~dark\z/, "")
135
+ dark = true
136
+ elsif name.sub!(/@([23])x\z/, "")
137
+ scale = "#{Regexp.last_match(1)}x"
138
+ else
139
+ break
140
+ end
141
+ end
142
+ [name, scale, dark]
143
+ end
144
+
145
+ def label(dir) = dir == "." ? "assets/" : "assets/#{dir}/"
146
+
147
+ def relative(path, source) = path.delete_prefix("#{source}/")
148
+ end
149
+ end
150
+ end