ruby_everywhere 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +99 -3
  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/agents_guide.rb +57 -0
  7. data/lib/everywhere/auth_handoff.rb +8 -2
  8. data/lib/everywhere/builders/desktop.rb +326 -0
  9. data/lib/everywhere/child_processes.rb +74 -0
  10. data/lib/everywhere/commands/build.rb +44 -5
  11. data/lib/everywhere/commands/dev.rb +426 -59
  12. data/lib/everywhere/commands/install.rb +93 -1
  13. data/lib/everywhere/commands/release.rb +2 -2
  14. data/lib/everywhere/config.rb +298 -4
  15. data/lib/everywhere/console.rb +117 -0
  16. data/lib/everywhere/desktop_assets.rb +150 -0
  17. data/lib/everywhere/dock/footer.rb +150 -0
  18. data/lib/everywhere/dock/screen.rb +114 -0
  19. data/lib/everywhere/dock/state.rb +59 -0
  20. data/lib/everywhere/dock.rb +238 -0
  21. data/lib/everywhere/emulator.rb +2 -2
  22. data/lib/everywhere/fatal.rb +20 -0
  23. data/lib/everywhere/line_pump.rb +89 -0
  24. data/lib/everywhere/log_filter.rb +37 -0
  25. data/lib/everywhere/native_helper.rb +38 -5
  26. data/lib/everywhere/paths.rb +62 -11
  27. data/lib/everywhere/relay.rb +77 -0
  28. data/lib/everywhere/shellout.rb +90 -15
  29. data/lib/everywhere/task_pool.rb +123 -0
  30. data/lib/everywhere/ui.rb +54 -11
  31. data/lib/everywhere/version.rb +1 -1
  32. data/support/agents/AGENTS.md +90 -0
  33. data/support/agents/frameworks/hanami-views.md +26 -0
  34. data/support/agents/frameworks/hanami.md +10 -0
  35. data/support/agents/frameworks/rails-views.md +98 -0
  36. data/support/agents/frameworks/rails.md +16 -0
  37. data/support/agents/frameworks/sinatra-views.md +24 -0
  38. data/support/agents/frameworks/sinatra.md +9 -0
  39. data/support/agents/modes/local.md +12 -0
  40. data/support/agents/modes/remote.md +14 -0
  41. data/support/desktop/README.md +121 -0
  42. data/support/{shell → desktop}/src-tauri/Cargo.lock +23 -0
  43. data/support/{shell → desktop}/src-tauri/Cargo.toml +19 -1
  44. data/support/{shell → desktop}/src-tauri/capabilities/default.json +7 -0
  45. data/support/desktop/src-tauri/gen/schemas/capabilities.json +1 -0
  46. data/support/desktop/src-tauri/src/extension_host.rs +53 -0
  47. data/support/desktop/src-tauri/src/extensions/mod.rs +39 -0
  48. data/support/{shell → desktop}/src-tauri/src/main.rs +355 -10
  49. data/support/{shell → desktop}/src-tauri/tauri.conf.json +2 -2
  50. data/support/{macos → release/macos}/notarize.sh +2 -2
  51. metadata +55 -22
  52. data/support/github/build.yml +0 -85
  53. data/support/shell/src-tauri/gen/schemas/capabilities.json +0 -1
  54. /data/support/{shell → desktop}/splash/index.html +0 -0
  55. /data/support/{shell → desktop}/src-tauri/build.rs +0 -0
  56. /data/support/{shell → desktop}/src-tauri/gen/schemas/acl-manifests.json +0 -0
  57. /data/support/{shell → desktop}/src-tauri/gen/schemas/desktop-schema.json +0 -0
  58. /data/support/{shell → desktop}/src-tauri/gen/schemas/macOS-schema.json +0 -0
  59. /data/support/{shell → desktop}/src-tauri/icons/icon.png +0 -0
  60. /data/support/{shell → desktop}/src-tauri/src/updater.rs +0 -0
  61. /data/support/{macos → release/macos}/entitlements.plist +0 -0
@@ -3,6 +3,8 @@
3
3
  require "dry/cli"
4
4
  require "fileutils"
5
5
  require_relative "../shellout"
6
+ require_relative "../config"
7
+ require_relative "../agents_guide"
6
8
 
7
9
  module Everywhere
8
10
  module Commands
@@ -13,8 +15,9 @@ module Everywhere
13
15
 
14
16
  option :root, default: ".", desc: "App root directory"
15
17
  option :local, type: :boolean, default: false, desc: "Configure local mode (compile & ship the app) instead of remote mode"
18
+ option :agents, type: :boolean, default: false, desc: "Write an AGENTS.md guide so AI coding agents understand this app"
16
19
 
17
- def call(root: ".", local: false, **)
20
+ def call(root: ".", local: false, agents: false, **)
18
21
  @mode = local ? "local" : "remote"
19
22
  @framework = Framework.detect(root)
20
23
  @root = @framework.root
@@ -29,6 +32,13 @@ module Everywhere
29
32
  # Framework-specific config surgery.
30
33
  send("install_#{@framework.name}")
31
34
 
35
+ if agents
36
+ write_agents_guide
37
+ write_claude_pointer
38
+ else
39
+ UI.step(UI.dim("tip: `every install --agents` writes an AGENTS.md guide for AI coding agents"))
40
+ end
41
+
32
42
  UI.success("installed — run `every dev` to open the app, `every build` to package it")
33
43
  end
34
44
 
@@ -158,6 +168,17 @@ module Everywhere
158
168
  # - label: Quit
159
169
  # action: quit
160
170
 
171
+ # Desktop window chrome. title_bar: overlay floats the macOS traffic
172
+ # lights over your page with no title text (reserve the space with the
173
+ # .everywhere-titlebar-inset class from everywhere/native.css);
174
+ # frameless removes the system chrome entirely, and the page draws its
175
+ # own title bar and calls Everywhere.window.* for the controls.
176
+ # window:
177
+ # title_bar: overlay # decorated (default) | overlay | frameless
178
+ # title: false # draw the title text
179
+ # size: [1100, 750]
180
+ # min_size: [600, 400]
181
+
161
182
  # Mobile tab bar (iOS today, Android soon). Served live from
162
183
  # /everywhere/ios_v1.json too, so tab changes deploy with the app —
163
184
  # no app-store release. Icons: ios = SF Symbol name.
@@ -177,6 +198,15 @@ module Everywhere
177
198
  # ios:
178
199
  # lazy_load_tabs: true # defer each tab's first load until it's tapped
179
200
  # # (false = load every tab up front; omit = default)
201
+ #
202
+ # Rust you keep in native/desktop/ and compile into the desktop
203
+ # shell. Declaring anything here switches the desktop build to a
204
+ # per-app cargo compile — the first one takes minutes.
205
+ # desktop:
206
+ # commands: [scan_ports] # reachable as Everywhere.desktop.invoke("scan_ports")
207
+ # setup: true # run native/desktop/setup.rs at boot
208
+ # crates:
209
+ # serialport: "4.3"
180
210
 
181
211
  # Durable build knobs (CLI flags still override per-run).
182
212
  # build:
@@ -227,6 +257,68 @@ module Everywhere
227
257
  change("create native_boot.rb", true)
228
258
  end
229
259
 
260
+ # --- AGENTS.md (--agents) --------------------------------------------------
261
+
262
+ FRESH_HEADER = "# AGENTS.md\n\nGuidance for AI coding agents working in this repository.\n\n"
263
+
264
+ # AGENTS.md is the app's file, not ours — most apps that have one already
265
+ # have notes in it. So we own only what's between the markers, and a re-run
266
+ # refreshes that region in place.
267
+ def write_agents_guide
268
+ path = app_file("AGENTS.md")
269
+ block = AgentsGuide.block(framework: @framework.name, mode: guide_mode)
270
+
271
+ unless File.exist?(path)
272
+ File.write(path, FRESH_HEADER + block)
273
+ return change("create AGENTS.md", true)
274
+ end
275
+
276
+ existing = File.read(path)
277
+ updated = merge_agents_block(existing, block) or return # markers are broken; warned
278
+ File.write(path, updated) unless updated == existing
279
+ change("refresh the RubyEverywhere section of AGENTS.md", updated != existing)
280
+ end
281
+
282
+ # Returns the merged file, or nil when the markers are malformed — a lone
283
+ # BEGIN would make the next run's region match swallow everything the app's
284
+ # authors wrote after it, so we refuse rather than guess at a repair.
285
+ def merge_agents_block(existing, block)
286
+ begins = existing.scan(AgentsGuide::BEGIN_MARKER).size
287
+ ends = existing.scan(AgentsGuide::END_MARKER).size
288
+
289
+ if begins.zero? && ends.zero?
290
+ return existing.strip.empty? ? block : "#{existing.chomp}\n\n#{block}"
291
+ end
292
+
293
+ if begins != 1 || ends != 1 ||
294
+ existing.index(AgentsGuide::BEGIN_MARKER) > existing.index(AgentsGuide::END_MARKER)
295
+ UI.warn("AGENTS.md has unbalanced #{AgentsGuide::BEGIN_MARKER} / #{AgentsGuide::END_MARKER} " \
296
+ "markers — leaving it alone; fix them by hand and re-run")
297
+ return nil
298
+ end
299
+
300
+ region = /^#{Regexp.escape(AgentsGuide::BEGIN_MARKER)}\n.*?^#{Regexp.escape(AgentsGuide::END_MARKER)}\n?/m
301
+ existing.sub(region) { block }
302
+ end
303
+
304
+ # Claude Code reads AGENTS.md on its own; the pointer is for the setups that
305
+ # only look at CLAUDE.md. An app that already has one has its own reasons for
306
+ # what's in it — leave it alone and let AGENTS.md be found the normal way.
307
+ def write_claude_pointer
308
+ claude = app_file("CLAUDE.md")
309
+ return change("create CLAUDE.md pointer", false) if File.exist?(claude)
310
+
311
+ File.write(claude, "See @AGENTS.md for how this app works and how RubyEverywhere packages it.\n")
312
+ change("create CLAUDE.md pointer", true)
313
+ end
314
+
315
+ # --local only describes what THIS run configured. Once config/everywhere.yml
316
+ # exists it is the truth, so a re-run without the flag can't mislabel a
317
+ # local-mode app as remote.
318
+ def guide_mode
319
+ File.exist?(app_file(Config::FILE)) ? Config.load(@root).mode : @mode
320
+ end
321
+
230
322
  def guard_bootsnap
231
323
  boot = app_file("config", "boot.rb")
232
324
  contents = File.read(boot)
@@ -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