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.
- checksums.yaml +4 -4
- data/README.md +99 -3
- data/bridge/README.md +70 -1
- data/bridge/everywhere/bridge.js +151 -0
- data/bridge/everywhere/native.css +61 -0
- data/lib/everywhere/agents_guide.rb +57 -0
- data/lib/everywhere/auth_handoff.rb +8 -2
- data/lib/everywhere/builders/desktop.rb +326 -0
- data/lib/everywhere/child_processes.rb +74 -0
- data/lib/everywhere/commands/build.rb +44 -5
- data/lib/everywhere/commands/dev.rb +426 -59
- data/lib/everywhere/commands/install.rb +93 -1
- data/lib/everywhere/commands/release.rb +2 -2
- data/lib/everywhere/config.rb +298 -4
- data/lib/everywhere/console.rb +117 -0
- data/lib/everywhere/desktop_assets.rb +150 -0
- data/lib/everywhere/dock/footer.rb +150 -0
- data/lib/everywhere/dock/screen.rb +114 -0
- data/lib/everywhere/dock/state.rb +59 -0
- data/lib/everywhere/dock.rb +238 -0
- data/lib/everywhere/emulator.rb +2 -2
- data/lib/everywhere/fatal.rb +20 -0
- data/lib/everywhere/line_pump.rb +89 -0
- data/lib/everywhere/log_filter.rb +37 -0
- data/lib/everywhere/native_helper.rb +38 -5
- data/lib/everywhere/paths.rb +62 -11
- data/lib/everywhere/relay.rb +77 -0
- data/lib/everywhere/shellout.rb +90 -15
- data/lib/everywhere/task_pool.rb +123 -0
- data/lib/everywhere/ui.rb +54 -11
- data/lib/everywhere/version.rb +1 -1
- data/support/agents/AGENTS.md +90 -0
- data/support/agents/frameworks/hanami-views.md +26 -0
- data/support/agents/frameworks/hanami.md +10 -0
- data/support/agents/frameworks/rails-views.md +98 -0
- data/support/agents/frameworks/rails.md +16 -0
- data/support/agents/frameworks/sinatra-views.md +24 -0
- data/support/agents/frameworks/sinatra.md +9 -0
- data/support/agents/modes/local.md +12 -0
- data/support/agents/modes/remote.md +14 -0
- data/support/desktop/README.md +121 -0
- data/support/{shell → desktop}/src-tauri/Cargo.lock +23 -0
- data/support/{shell → desktop}/src-tauri/Cargo.toml +19 -1
- data/support/{shell → desktop}/src-tauri/capabilities/default.json +7 -0
- data/support/desktop/src-tauri/gen/schemas/capabilities.json +1 -0
- data/support/desktop/src-tauri/src/extension_host.rs +53 -0
- data/support/desktop/src-tauri/src/extensions/mod.rs +39 -0
- data/support/{shell → desktop}/src-tauri/src/main.rs +355 -10
- data/support/{shell → desktop}/src-tauri/tauri.conf.json +2 -2
- data/support/{macos → release/macos}/notarize.sh +2 -2
- metadata +55 -22
- data/support/github/build.yml +0 -85
- data/support/shell/src-tauri/gen/schemas/capabilities.json +0 -1
- /data/support/{shell → desktop}/splash/index.html +0 -0
- /data/support/{shell → desktop}/src-tauri/build.rs +0 -0
- /data/support/{shell → desktop}/src-tauri/gen/schemas/acl-manifests.json +0 -0
- /data/support/{shell → desktop}/src-tauri/gen/schemas/desktop-schema.json +0 -0
- /data/support/{shell → desktop}/src-tauri/gen/schemas/macOS-schema.json +0 -0
- /data/support/{shell → desktop}/src-tauri/icons/icon.png +0 -0
- /data/support/{shell → desktop}/src-tauri/src/updater.rs +0 -0
- /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
|
|
data/lib/everywhere/config.rb
CHANGED
|
@@ -92,18 +92,45 @@ module Everywhere
|
|
|
92
92
|
HTML
|
|
93
93
|
end
|
|
94
94
|
|
|
95
|
-
#
|
|
96
|
-
#
|
|
97
|
-
#
|
|
98
|
-
#
|
|
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
|