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.
- checksums.yaml +4 -4
- data/README.md +86 -2
- data/bridge/README.md +70 -1
- data/bridge/everywhere/bridge.js +151 -0
- data/bridge/everywhere/native.css +61 -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 +20 -0
- 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/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 +31 -17
- 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
|
@@ -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
|
|
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
|
|
@@ -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
|