ruby_everywhere 0.6.0 → 0.8.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/exe/every +2 -2
- data/exe/rbe +2 -2
- data/lib/everywhere/agents_guide.rb +3 -1
- data/lib/everywhere/blake2b.rb +17 -1
- data/lib/everywhere/boot.rb +30 -6
- data/lib/everywhere/builders/android.rb +152 -64
- data/lib/everywhere/builders/base.rb +53 -0
- data/lib/everywhere/builders/desktop.rb +32 -40
- data/lib/everywhere/builders/ios.rb +268 -36
- data/lib/everywhere/builders/native_sources.rb +38 -0
- data/lib/everywhere/child_processes.rb +4 -4
- data/lib/everywhere/child_supervision.rb +172 -0
- data/lib/everywhere/cli.rb +2 -0
- data/lib/everywhere/clock.rb +12 -0
- data/lib/everywhere/commands/build.rb +132 -66
- data/lib/everywhere/commands/clean.rb +8 -3
- data/lib/everywhere/commands/dev.rb +91 -242
- data/lib/everywhere/commands/doctor.rb +138 -21
- data/lib/everywhere/commands/install.rb +49 -8
- data/lib/everywhere/commands/platform/auth_status.rb +1 -0
- data/lib/everywhere/commands/platform/build.rb +141 -33
- data/lib/everywhere/commands/platform/login.rb +20 -6
- data/lib/everywhere/commands/platform/logout.rb +1 -0
- data/lib/everywhere/commands/platform/runner.rb +170 -25
- data/lib/everywhere/commands/preview.rb +286 -0
- data/lib/everywhere/commands/publish.rb +22 -2
- data/lib/everywhere/commands/release.rb +155 -35
- data/lib/everywhere/commands/shell_dir.rb +4 -2
- data/lib/everywhere/commands/updates_keygen.rb +25 -1
- data/lib/everywhere/config/app.rb +126 -0
- data/lib/everywhere/config/auth.rb +108 -0
- data/lib/everywhere/config/data.rb +50 -0
- data/lib/everywhere/config/deep_linking.rb +107 -0
- data/lib/everywhere/config/desktop_ui.rb +153 -0
- data/lib/everywhere/config/mobile.rb +211 -0
- data/lib/everywhere/config/native_desktop.rb +168 -0
- data/lib/everywhere/config/native_mobile.rb +337 -0
- data/lib/everywhere/config/shell.rb +57 -0
- data/lib/everywhere/config/updates.rb +63 -0
- data/lib/everywhere/config.rb +59 -1367
- data/lib/everywhere/console.rb +2 -1
- data/lib/everywhere/desktop_dev_app.rb +138 -0
- data/lib/everywhere/dock/state.rb +3 -1
- data/lib/everywhere/engine.rb +24 -0
- data/lib/everywhere/entrypoint.rb +24 -0
- data/lib/everywhere/error.rb +8 -0
- data/lib/everywhere/framework.rb +23 -6
- data/lib/everywhere/host.rb +11 -0
- data/lib/everywhere/ignore.rb +13 -5
- data/lib/everywhere/jump.rb +121 -0
- data/lib/everywhere/line_pump.rb +3 -1
- data/lib/everywhere/minisign.rb +1 -0
- data/lib/everywhere/mobile_config_endpoint.rb +47 -0
- data/lib/everywhere/mobile_configs_controller.rb +46 -0
- data/lib/everywhere/native_platform.rb +44 -0
- data/lib/everywhere/paths.rb +36 -13
- data/lib/everywhere/platform/client.rb +46 -7
- data/lib/everywhere/platform/credentials.rb +18 -8
- data/lib/everywhere/platform/snapshot.rb +12 -0
- data/lib/everywhere/plist.rb +17 -0
- data/lib/everywhere/png.rb +1 -0
- data/lib/everywhere/raw_tty.rb +51 -0
- data/lib/everywhere/receipt.rb +55 -10
- data/lib/everywhere/s3.rb +1 -0
- data/lib/everywhere/shell_pages.rb +109 -0
- data/lib/everywhere/shellout.rb +19 -0
- data/lib/everywhere/simulator.rb +12 -1
- data/lib/everywhere/tab_filter.rb +36 -0
- data/lib/everywhere/task_pool.rb +3 -4
- data/lib/everywhere/ui.rb +6 -1
- data/lib/everywhere/version.rb +6 -4
- data/lib/everywhere.rb +1 -2
- data/support/mobile/android/app/build.gradle.kts +29 -1
- data/support/mobile/ios/App/EverywhereConfig.swift +17 -5
- data/support/mobile/ios/App/SceneDelegate.swift +75 -8
- data/support/mobile/ios/App.xcodeproj/project.pbxproj +2 -4
- data/support/mobile/ios/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +0 -1
- data/support/mobile/ios/README.md +13 -6
- data/support/release/macos/notarize.sh +3 -0
- metadata +40 -1
data/lib/everywhere/console.rb
CHANGED
|
@@ -36,7 +36,8 @@ module Everywhere
|
|
|
36
36
|
desktop: ["desktop", :magenta],
|
|
37
37
|
ios: ["ios", :blue],
|
|
38
38
|
android: ["android", :green],
|
|
39
|
-
logs: ["logs", :gray]
|
|
39
|
+
logs: ["logs", :gray],
|
|
40
|
+
tunnel: ["tunnel", :yellow]
|
|
40
41
|
}.freeze
|
|
41
42
|
|
|
42
43
|
TAG_WIDTH = TAGS.values.map { |label, _| label.length }.max
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "shellwords"
|
|
5
|
+
require_relative "desktop_assets"
|
|
6
|
+
require_relative "host"
|
|
7
|
+
require_relative "icon"
|
|
8
|
+
require_relative "paths"
|
|
9
|
+
require_relative "plist"
|
|
10
|
+
require_relative "ui"
|
|
11
|
+
|
|
12
|
+
module Everywhere
|
|
13
|
+
# What `every dev` stages under ~/.rubyeverywhere so a live `cargo run` shell
|
|
14
|
+
# looks like the app being built: the flat assets folder, the Dock icon, and
|
|
15
|
+
# on macOS a throwaway .app around the dev binary — plus the command that
|
|
16
|
+
# links the two together and launches it.
|
|
17
|
+
#
|
|
18
|
+
# None of this is packaging. `every build` writes the real, complete bundle;
|
|
19
|
+
# this is the subset a dev session needs, restaged on every launch so
|
|
20
|
+
# editing an asset and pressing `d` is enough to see it.
|
|
21
|
+
class DesktopDevApp
|
|
22
|
+
def initialize(config:)
|
|
23
|
+
@config = config
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# native/desktop/assets/** → the staged flat folder the running shell
|
|
27
|
+
# reads. Runs the same compiler `every build` does, so an asset that
|
|
28
|
+
# resolves in dev resolves in the packaged app too. Returns
|
|
29
|
+
# [dir, errors]; the dir is returned even when the app ships no assets,
|
|
30
|
+
# because the shell only has to cope with it not existing.
|
|
31
|
+
def stage_desktop_assets
|
|
32
|
+
dest = Everywhere::Paths.desktop_assets_dir(@config.bundle_id)
|
|
33
|
+
source = File.join(@config.root, "native", "desktop", "assets")
|
|
34
|
+
_names, errors = Everywhere::DesktopAssets.write!(source, dest)
|
|
35
|
+
[dest, errors]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# A minimal .app around the dev binary, so macOS has a CFBundleName to
|
|
39
|
+
# read. Without it every menu that names the app says "example-shell" —
|
|
40
|
+
# the crate name — because an unbundled process has nothing else to go on.
|
|
41
|
+
# (Setting NSProcessInfo's processName at runtime does NOT fix this: the
|
|
42
|
+
# menu bar and the predefined About/Hide/Quit items are built from the
|
|
43
|
+
# bundle, and macOS has already decided by the time any Rust runs.)
|
|
44
|
+
#
|
|
45
|
+
# Only the plist matters here. No icon is stamped: the shell sets the Dock
|
|
46
|
+
# icon at runtime from NATIVE_ICON_PATH, because Tauri applies its own
|
|
47
|
+
# compiled-in icon after launch and would win over a bundle icns anyway.
|
|
48
|
+
# `every build` writes the real, complete plist — this is the dev subset.
|
|
49
|
+
def stage_dev_bundle
|
|
50
|
+
app = File.join(Everywhere::Paths.desktop_dev_app_dir(@config.bundle_id),
|
|
51
|
+
"#{dev_bundle_name}.app")
|
|
52
|
+
macos = File.join(app, "Contents", "MacOS")
|
|
53
|
+
FileUtils.mkdir_p(macos)
|
|
54
|
+
File.write(File.join(app, "Contents", "Info.plist"), dev_info_plist)
|
|
55
|
+
[app, File.join(macos, dev_exe_name)]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Display names are free-form (spaces, punctuation, emoji all fine), but
|
|
59
|
+
# this one becomes a directory name — so the separators, and a leading dot
|
|
60
|
+
# that would hide or escape the bundle, are the only things taken out.
|
|
61
|
+
def dev_bundle_name
|
|
62
|
+
cleaned = @config.name.to_s.gsub(%r{[/\\]}, "-").sub(/\A\.+/, "").strip
|
|
63
|
+
cleaned.empty? ? "App" : cleaned
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Spaces are legal in CFBundleExecutable but a nuisance in every shell
|
|
67
|
+
# command that names it, so the executable follows the packaged app's
|
|
68
|
+
# convention and drops them.
|
|
69
|
+
def dev_exe_name = dev_bundle_name.gsub(/\s+/, "")
|
|
70
|
+
|
|
71
|
+
def dev_info_plist
|
|
72
|
+
name = Everywhere::Plist.escape(@config.name)
|
|
73
|
+
<<~PLIST
|
|
74
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
75
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
76
|
+
<plist version="1.0">
|
|
77
|
+
<dict>
|
|
78
|
+
<key>CFBundleName</key><string>#{name}</string>
|
|
79
|
+
<key>CFBundleDisplayName</key><string>#{name}</string>
|
|
80
|
+
<key>CFBundleExecutable</key><string>#{Everywhere::Plist.escape(dev_exe_name)}</string>
|
|
81
|
+
<key>CFBundleIdentifier</key><string>#{Everywhere::Plist.escape(@config.bundle_id)}</string>
|
|
82
|
+
<key>CFBundlePackageType</key><string>APPL</string>
|
|
83
|
+
<key>CFBundleShortVersionString</key><string>#{Everywhere::Plist.escape(@config.version)}</string>
|
|
84
|
+
<key>NSHighResolutionCapable</key><true/>
|
|
85
|
+
</dict>
|
|
86
|
+
</plist>
|
|
87
|
+
PLIST
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# The Dock icon for the running shell. `cargo run` produces a bare binary
|
|
91
|
+
# with no .app around it, so macOS has nothing to read an icon from and
|
|
92
|
+
# Tauri's compiled-in placeholder wins — which is how you end up staring
|
|
93
|
+
# at the gem's icon while testing your own app.
|
|
94
|
+
#
|
|
95
|
+
# Shaped with the same Big Sur inset + squircle the packaged .icns gets
|
|
96
|
+
# (Icon.macos_master), so the Dock looks identical in dev and in a release
|
|
97
|
+
# build rather than subtly rounder in one of them. Returns "" when the app
|
|
98
|
+
# has no icon — the shell reads that as "leave it alone".
|
|
99
|
+
def stage_desktop_icon
|
|
100
|
+
source = @config.icon
|
|
101
|
+
return "" unless source && File.exist?(source)
|
|
102
|
+
|
|
103
|
+
dest = File.join(Everywhere::Paths.desktop_icon_dir(@config.bundle_id), "icon.png")
|
|
104
|
+
FileUtils.mkdir_p(File.dirname(dest))
|
|
105
|
+
FileUtils.cp(source, dest) unless Everywhere::Icon.macos_master(source, dest)
|
|
106
|
+
dest
|
|
107
|
+
rescue StandardError => e
|
|
108
|
+
# A Dock icon is never worth failing a dev session over.
|
|
109
|
+
UI.warn("couldn't prepare the Dock icon (#{e.class}) — using the default")
|
|
110
|
+
""
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# macOS runs the shell from inside a throwaway .app so it has a name;
|
|
114
|
+
# everywhere else `cargo run` is still the whole story.
|
|
115
|
+
#
|
|
116
|
+
# Three steps in one command rather than three Ruby calls, because the
|
|
117
|
+
# link has to happen between the compile and the launch and we want ONE
|
|
118
|
+
# supervised child: cargo's progress relays into the dock, and exec then
|
|
119
|
+
# replaces it with the app, so the pid we're tracking is the window.
|
|
120
|
+
#
|
|
121
|
+
# A hard link, not a copy: same volume (both under ~/.rubyeverywhere), so
|
|
122
|
+
# it's free and instant, where copying a debug Tauri binary is ~100MB on
|
|
123
|
+
# every launch. cargo replaces the file on rebuild rather than writing
|
|
124
|
+
# through it, so the link is remade each time — hence `ln -f`.
|
|
125
|
+
def desktop_command(prepared)
|
|
126
|
+
return "cargo run" unless Host.darwin?
|
|
127
|
+
|
|
128
|
+
_app, exe = stage_dev_bundle
|
|
129
|
+
built = File.join(prepared.target_dir, "debug", "example-shell")
|
|
130
|
+
[
|
|
131
|
+
"cargo build",
|
|
132
|
+
"{ ln -f #{built.shellescape} #{exe.shellescape} 2>/dev/null || " \
|
|
133
|
+
"cp -f #{built.shellescape} #{exe.shellescape}; }",
|
|
134
|
+
"exec #{exe.shellescape}"
|
|
135
|
+
].join(" && ")
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../clock"
|
|
4
|
+
|
|
3
5
|
module Everywhere
|
|
4
6
|
class Dock
|
|
5
7
|
# What the dock knows about each thing `every dev` is running. Pure data
|
|
@@ -22,7 +24,7 @@ module Everywhere
|
|
|
22
24
|
def busy? = BUSY.include?(state)
|
|
23
25
|
end
|
|
24
26
|
|
|
25
|
-
def initialize(keys, clock: -> {
|
|
27
|
+
def initialize(keys, clock: -> { Clock.monotonic })
|
|
26
28
|
@clock = clock
|
|
27
29
|
@lock = Mutex.new
|
|
28
30
|
ordered = ORDER & keys
|
data/lib/everywhere/engine.rb
CHANGED
|
@@ -51,6 +51,14 @@ module Everywhere
|
|
|
51
51
|
app.middleware.insert_before 0, Everywhere::AuthHandoff, root: app.root.to_s
|
|
52
52
|
end
|
|
53
53
|
|
|
54
|
+
# `every preview` fronts the dev server with a tunnel whose hostname is
|
|
55
|
+
# only known at spawn time, so it arrives by env var; without this, Rails'
|
|
56
|
+
# host authorization greets every phone with a blocked-host page.
|
|
57
|
+
initializer "everywhere.preview_host" do |app|
|
|
58
|
+
host = ENV["EVERYWHERE_PREVIEW_HOST"].to_s
|
|
59
|
+
app.config.hosts << host if app.config.respond_to?(:hosts) && !host.empty?
|
|
60
|
+
end
|
|
61
|
+
|
|
54
62
|
# /everywhere/ios_v1.json — the mobile shells' remote path configuration
|
|
55
63
|
# (rules + settings.tabs), generated from config/everywhere.yml at request
|
|
56
64
|
# time so tab changes ship with a normal deploy. A real controller (not
|
|
@@ -64,6 +72,22 @@ module Everywhere
|
|
|
64
72
|
require_relative "mobile_configs_controller"
|
|
65
73
|
get "/everywhere/reset", to: Everywhere::MobileConfigsController.action(:reset),
|
|
66
74
|
as: :everywhere_reset
|
|
75
|
+
# Jump preview endpoints — dead (404) outside `every preview` runs.
|
|
76
|
+
# OPTIONS routes are the CORS preflights: the connect page calling
|
|
77
|
+
# these lives on the Jump site, another origin by definition.
|
|
78
|
+
get "/everywhere/jump_v1", to: Everywhere::MobileConfigsController.action(:jump_manifest),
|
|
79
|
+
defaults: { format: "json" },
|
|
80
|
+
as: :everywhere_jump_manifest
|
|
81
|
+
match "/everywhere/jump_v1", to: Everywhere::MobileConfigsController.action(:jump_manifest),
|
|
82
|
+
via: :options
|
|
83
|
+
post "/everywhere/jump/session", to: Everywhere::MobileConfigsController.action(:jump_session),
|
|
84
|
+
as: :everywhere_jump_session
|
|
85
|
+
match "/everywhere/jump/session", to: Everywhere::MobileConfigsController.action(:jump_session),
|
|
86
|
+
via: :options
|
|
87
|
+
# The way back out of a preview — served on EVERY app with this gem,
|
|
88
|
+
# because after instance.set the previewed app is all Jump can reach.
|
|
89
|
+
get "/everywhere/jump/leave", to: Everywhere::MobileConfigsController.action(:jump_leave),
|
|
90
|
+
as: :everywhere_jump_leave
|
|
67
91
|
get "/everywhere/:platform_v1", to: Everywhere::MobileConfigsController.action(:show),
|
|
68
92
|
constraints: { platform_v1: /(?:ios|android)_v1/ },
|
|
69
93
|
defaults: { format: "json" },
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "cli"
|
|
4
|
+
require_relative "ui"
|
|
5
|
+
|
|
6
|
+
module Everywhere
|
|
7
|
+
# The body of both executables — `every` and `rbe` are the same CLI under two
|
|
8
|
+
# names, so exe/* stay one-liners that call in here.
|
|
9
|
+
module Entrypoint
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def run(argv = ARGV)
|
|
13
|
+
Dry::CLI.new(Everywhere::CLI).call(arguments: argv)
|
|
14
|
+
rescue Interrupt
|
|
15
|
+
# ^C is an answer, not a crash: no backtrace, and the shell's own convention
|
|
16
|
+
# for "killed by SIGINT".
|
|
17
|
+
exit 130
|
|
18
|
+
rescue Everywhere::Error => e
|
|
19
|
+
# Everywhere::Fatal is a SystemExit, not an Error, so a die! that already
|
|
20
|
+
# printed its own line passes straight through here.
|
|
21
|
+
Everywhere::UI.die!(e.message)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Everywhere
|
|
4
|
+
# Required on its own by every file that raises it, so each stays loadable
|
|
5
|
+
# standalone (the packaged runtime requires them piecemeal, without going
|
|
6
|
+
# through everywhere.rb).
|
|
7
|
+
class Error < StandardError; end
|
|
8
|
+
end
|
data/lib/everywhere/framework.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "error"
|
|
4
|
+
|
|
3
5
|
module Everywhere
|
|
4
6
|
# Framework adapters keep both the CLI (dev/build) and the packaged runtime
|
|
5
7
|
# (boot) framework-agnostic. Each adapter owns two sides of one framework:
|
|
@@ -32,9 +34,24 @@ module Everywhere
|
|
|
32
34
|
File.exist?(File.join(root, "config.ru")) && gemfile_mentions?(root, "sinatra")
|
|
33
35
|
end
|
|
34
36
|
|
|
37
|
+
# Bundler reads either name, and `gem "rails"` / `gem("rails")` are the same
|
|
38
|
+
# line to it. Anchored at the start of the line so a commented-out gem — the
|
|
39
|
+
# usual reason a Gemfile still mentions one — doesn't count as detection.
|
|
40
|
+
GEMFILE_NAMES = %w[Gemfile gems.rb].freeze
|
|
41
|
+
|
|
35
42
|
def self.gemfile_mentions?(root, gem_name)
|
|
36
|
-
|
|
37
|
-
|
|
43
|
+
pattern = /^\s*gem[\s(]+["']#{Regexp.escape(gem_name)}/
|
|
44
|
+
GEMFILE_NAMES.any? do |name|
|
|
45
|
+
path = File.join(root, name)
|
|
46
|
+
File.exist?(path) && read_source(path).match?(pattern)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Gemfiles are UTF-8, but the process may be running under LANG=C, where
|
|
51
|
+
# File.read tags the bytes as US-ASCII and any accented comment makes the
|
|
52
|
+
# match raise instead of answering.
|
|
53
|
+
def self.read_source(path)
|
|
54
|
+
File.read(path, encoding: "UTF-8").scrub
|
|
38
55
|
end
|
|
39
56
|
|
|
40
57
|
attr_reader :root
|
|
@@ -120,8 +137,10 @@ module Everywhere
|
|
|
120
137
|
def name = "rails"
|
|
121
138
|
|
|
122
139
|
# CLI
|
|
140
|
+
# executable?, not exist?: a repo downloaded as a zip loses the exec bit,
|
|
141
|
+
# and running bin/dev then dies with a bare "Permission denied".
|
|
123
142
|
def dev_command
|
|
124
|
-
File.
|
|
143
|
+
File.executable?(File.join(root, "bin", "dev")) ? "bin/dev" : "bundle exec rails server"
|
|
125
144
|
end
|
|
126
145
|
|
|
127
146
|
def precompile_env
|
|
@@ -187,7 +206,7 @@ module Everywhere
|
|
|
187
206
|
def name = "sinatra"
|
|
188
207
|
|
|
189
208
|
def dev_command
|
|
190
|
-
File.
|
|
209
|
+
File.executable?(File.join(root, "bin", "dev")) ? "bin/dev" : "bundle exec rackup --host 127.0.0.1"
|
|
191
210
|
end
|
|
192
211
|
|
|
193
212
|
# No asset pipeline by default; nothing to precompile.
|
|
@@ -206,6 +225,4 @@ module Everywhere
|
|
|
206
225
|
def env_exports = { "HANAMI_ENV" => "production", "RACK_ENV" => "production" }
|
|
207
226
|
end
|
|
208
227
|
end
|
|
209
|
-
|
|
210
|
-
class Error < StandardError; end
|
|
211
228
|
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Everywhere
|
|
4
|
+
# The machine the CLI is running on — never the machine it's building for
|
|
5
|
+
# (that's the --target, see Config.os_of and the receipt).
|
|
6
|
+
module Host
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def darwin? = RUBY_PLATFORM.include?("darwin")
|
|
10
|
+
end
|
|
11
|
+
end
|
data/lib/everywhere/ignore.rb
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Everywhere
|
|
4
|
-
# Shared source-exclusion rules,
|
|
5
|
-
# style) when
|
|
4
|
+
# Shared source-exclusion rules: the built-in DEFAULTS, plus `.everywhereignore`
|
|
5
|
+
# (dockerignore style) appended when the app has one. ONE source of truth so the
|
|
6
6
|
# build receipt's source checksum (Receipt) and the Platform snapshot
|
|
7
7
|
# (`every platform build`, later) describe exactly the same set of files — a
|
|
8
8
|
# checksum that didn't match what actually ships would be worse than none.
|
|
@@ -15,7 +15,8 @@ module Everywhere
|
|
|
15
15
|
#
|
|
16
16
|
# Rules are evaluated top to bottom and the LAST one that matches a path wins
|
|
17
17
|
# (dockerignore semantics), so ordering matters: put `!` re-includes after the
|
|
18
|
-
# broad exclude they carve out of
|
|
18
|
+
# broad exclude they carve out of — and the app's file, coming last, can
|
|
19
|
+
# re-include anything DEFAULTS excluded.
|
|
19
20
|
class Ignore
|
|
20
21
|
FILE = ".everywhereignore"
|
|
21
22
|
FLAGS = File::FNM_PATHNAME | File::FNM_DOTMATCH
|
|
@@ -29,10 +30,17 @@ module Everywhere
|
|
|
29
30
|
*.key *.pem *.crt *.p12 *.mobileprovision
|
|
30
31
|
].freeze
|
|
31
32
|
|
|
33
|
+
# The app's file is layered ON TOP of DEFAULTS rather than replacing them:
|
|
34
|
+
# a project that adds one line to skip a fixture dir must not thereby start
|
|
35
|
+
# shipping .env and *.p12. Last match wins, so a user who really wants a
|
|
36
|
+
# defaulted-out file back can still `!` it in.
|
|
32
37
|
def self.for(root)
|
|
33
38
|
path = File.join(root, FILE)
|
|
34
|
-
|
|
35
|
-
|
|
39
|
+
return new(parse(DEFAULTS.join("\n"))) unless File.exist?(path)
|
|
40
|
+
|
|
41
|
+
# UTF-8 explicitly (and scrubbed): under LANG=C the bytes come back
|
|
42
|
+
# US-ASCII and a non-ASCII path pattern makes the matching raise.
|
|
43
|
+
new(parse(DEFAULTS.join("\n")) + parse(File.read(path, encoding: "UTF-8").scrub))
|
|
36
44
|
end
|
|
37
45
|
|
|
38
46
|
# → [{ negated:, pattern: }, ...] in file order. Blank lines and whole-line
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
|
|
6
|
+
module Everywhere
|
|
7
|
+
# Jump: the companion app that previews RubyEverywhere apps on a real
|
|
8
|
+
# device (spec: platform/docs/jump.md). The server side is two endpoints,
|
|
9
|
+
# live only while `every preview` runs (EVERYWHERE_JUMP_TOKEN set):
|
|
10
|
+
#
|
|
11
|
+
# POST /everywhere/jump/session {token, device_name} -> {bearer}
|
|
12
|
+
# GET /everywhere/jump_v1.json Authorization: Bearer ... -> manifest
|
|
13
|
+
#
|
|
14
|
+
# The pairing token is per-run and multi-use (a forwarded link must work
|
|
15
|
+
# for a second coworker's device); each device exchanges it once for a
|
|
16
|
+
# bearer. Bearers are stateless HMACs keyed on the token — no session
|
|
17
|
+
# store, and every bearer dies with the run because the token does.
|
|
18
|
+
module Jump
|
|
19
|
+
PROTOCOL = 1
|
|
20
|
+
SESSION_PATH = "/everywhere/jump/session"
|
|
21
|
+
MANIFEST_PATH = "/everywhere/jump_v1.json"
|
|
22
|
+
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
def token = ENV["EVERYWHERE_JUMP_TOKEN"]
|
|
26
|
+
def enabled? = !token.to_s.empty?
|
|
27
|
+
|
|
28
|
+
def manifest_hash(config)
|
|
29
|
+
{
|
|
30
|
+
"version" => 1,
|
|
31
|
+
"jump_protocol" => PROTOCOL,
|
|
32
|
+
"app" => { "name" => config.name, "identifier" => config.bundle_id },
|
|
33
|
+
"appearance" => {
|
|
34
|
+
"tint_color" => config.tint_color,
|
|
35
|
+
"background_color" => config.background_color
|
|
36
|
+
}.compact,
|
|
37
|
+
"entry_path" => config.entry_path,
|
|
38
|
+
"remote_instances" => (true if config.remote_instances?),
|
|
39
|
+
"path_configuration" => {
|
|
40
|
+
"ios" => "/everywhere/ios_v1.json",
|
|
41
|
+
"android" => "/everywhere/android_v1.json"
|
|
42
|
+
},
|
|
43
|
+
"native_extensions" => [
|
|
44
|
+
("ios" if config.native_ios?),
|
|
45
|
+
("android" if config.native_android?)
|
|
46
|
+
].compact
|
|
47
|
+
}.compact
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# nil on a bad token; the session hash otherwise. Logs to stderr so the
|
|
51
|
+
# connect shows up in the terminal `every preview` is pumping.
|
|
52
|
+
def exchange(candidate, device_name)
|
|
53
|
+
return nil unless valid_token?(candidate)
|
|
54
|
+
|
|
55
|
+
warn connect_log_line(device_name)
|
|
56
|
+
{ "bearer" => mint_bearer, "jump_protocol" => PROTOCOL }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def mint_bearer(tok = token)
|
|
60
|
+
nonce = SecureRandom.hex(12)
|
|
61
|
+
"v1.#{nonce}.#{signature(tok, nonce)}"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def valid_bearer?(bearer, tok = token)
|
|
65
|
+
version, nonce, mac = bearer.to_s.split(".", 3)
|
|
66
|
+
return false unless version == "v1" && nonce && mac && !tok.to_s.empty?
|
|
67
|
+
|
|
68
|
+
expected = signature(tok, nonce)
|
|
69
|
+
mac.bytesize == expected.bytesize && OpenSSL.fixed_length_secure_compare(mac, expected)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def valid_token?(candidate, tok = token)
|
|
73
|
+
candidate = candidate.to_s
|
|
74
|
+
return false if tok.to_s.empty? || candidate.empty?
|
|
75
|
+
|
|
76
|
+
candidate.bytesize == tok.bytesize && OpenSSL.fixed_length_secure_compare(candidate, tok)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def signature(tok, nonce)
|
|
80
|
+
OpenSSL::HMAC.hexdigest("SHA256", tok, "jump-session-v1:#{nonce}")
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
LEAVE_PATH = "/everywhere/jump/leave"
|
|
84
|
+
|
|
85
|
+
# The connect page lives on the Jump site; the endpoints live on the
|
|
86
|
+
# previewed app — always cross-origin. `*` is safe here: nothing is
|
|
87
|
+
# cookie-authenticated, the token/bearer IS the credential.
|
|
88
|
+
CORS_HEADERS = {
|
|
89
|
+
"access-control-allow-origin" => "*",
|
|
90
|
+
"access-control-allow-methods" => "GET, POST, OPTIONS",
|
|
91
|
+
"access-control-allow-headers" => "authorization, content-type",
|
|
92
|
+
"access-control-max-age" => "600"
|
|
93
|
+
}.freeze
|
|
94
|
+
|
|
95
|
+
# Appended to the previewed app's resolved tabs while preview runs: after
|
|
96
|
+
# instance.set the previewed app's path configuration is the ONLY chrome
|
|
97
|
+
# Jump has, so without this tab there is no way back to the launcher. A
|
|
98
|
+
# tabless app gets a pair — its own content as tab one, the exit as tab
|
|
99
|
+
# two — because a lone exit tab would replace the app's content outright.
|
|
100
|
+
def exit_tabs(config, os, tabs)
|
|
101
|
+
return tabs + [exit_tab(os)] unless tabs.empty?
|
|
102
|
+
|
|
103
|
+
app_tab = { "title" => config.name,
|
|
104
|
+
"path" => config.entry_path || "/",
|
|
105
|
+
"icon" => os.to_s == "android" ? "apps" : "app" }
|
|
106
|
+
[app_tab, exit_tab(os)]
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def exit_tab(os)
|
|
110
|
+
{ "title" => "Jump",
|
|
111
|
+
"path" => LEAVE_PATH,
|
|
112
|
+
"icon" => os.to_s == "android" ? "u_turn_left" : "arrow.uturn.backward.circle" }
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def connect_log_line(device_name)
|
|
116
|
+
name = device_name.to_s.gsub(/[[:cntrl:]]/, "").strip[0, 64]
|
|
117
|
+
name = "A device" if name.empty?
|
|
118
|
+
"[everywhere] Jump connected: #{name}"
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
data/lib/everywhere/line_pump.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "clock"
|
|
4
|
+
|
|
3
5
|
module Everywhere
|
|
4
6
|
# Turns the raw byte stream off a pty master into whole lines.
|
|
5
7
|
#
|
|
@@ -25,7 +27,7 @@ module Everywhere
|
|
|
25
27
|
# "Overwrite? [Yn]" — would be invisible for as long as it mattered.
|
|
26
28
|
IDLE_FLUSH = 0.2
|
|
27
29
|
|
|
28
|
-
def initialize(idle_flush: IDLE_FLUSH, clock: -> {
|
|
30
|
+
def initialize(idle_flush: IDLE_FLUSH, clock: -> { Clock.monotonic })
|
|
29
31
|
# Bytes in, text out. A pty hands over BINARY, and a chunk boundary can
|
|
30
32
|
# land in the middle of a multi-byte character, so buffering and splitting
|
|
31
33
|
# happen on bytes — UTF-8 is self-synchronizing, so a newline is never
|
data/lib/everywhere/minisign.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "config"
|
|
4
|
+
require_relative "jump"
|
|
4
5
|
|
|
5
6
|
module Everywhere
|
|
6
7
|
# Rack middleware serving the mobile shells' remote path configuration:
|
|
@@ -32,6 +33,26 @@ module Everywhere
|
|
|
32
33
|
end
|
|
33
34
|
|
|
34
35
|
def call(env)
|
|
36
|
+
# Jump endpoints exist only while `every preview` runs (pass through
|
|
37
|
+
# otherwise, so nothing is reachable on a normal deploy). The leave page
|
|
38
|
+
# is the exception — it's how Jump escapes a previewed app, so it must
|
|
39
|
+
# answer whenever this gem is present.
|
|
40
|
+
if Jump.enabled?
|
|
41
|
+
if env["REQUEST_METHOD"] == "OPTIONS" &&
|
|
42
|
+
[Jump::SESSION_PATH, Jump::MANIFEST_PATH].include?(env["PATH_INFO"])
|
|
43
|
+
return [204, Jump::CORS_HEADERS.dup, []]
|
|
44
|
+
end
|
|
45
|
+
if env["REQUEST_METHOD"] == "POST" && env["PATH_INFO"] == Jump::SESSION_PATH
|
|
46
|
+
return jump_session(env)
|
|
47
|
+
end
|
|
48
|
+
if env["REQUEST_METHOD"] == "GET" && env["PATH_INFO"] == Jump::MANIFEST_PATH
|
|
49
|
+
return jump_manifest(env)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
if env["REQUEST_METHOD"] == "GET" && env["PATH_INFO"] == Jump::LEAVE_PATH
|
|
53
|
+
return html(Everywhere.mobile_jump_leave_html)
|
|
54
|
+
end
|
|
55
|
+
|
|
35
56
|
return @app.call(env) unless env["REQUEST_METHOD"] == "GET"
|
|
36
57
|
|
|
37
58
|
if env["PATH_INFO"] == RESET
|
|
@@ -56,11 +77,37 @@ module Everywhere
|
|
|
56
77
|
os = match[1]
|
|
57
78
|
config = Config.load(@root)
|
|
58
79
|
tabs = Everywhere.resolve_tabs(config.tabs_for(os), request_for(env))
|
|
80
|
+
tabs = Jump.exit_tabs(config, os, tabs) if Jump.enabled?
|
|
59
81
|
json(config.path_configuration_json(os, tabs: tabs))
|
|
60
82
|
end
|
|
61
83
|
|
|
62
84
|
private
|
|
63
85
|
|
|
86
|
+
def jump_session(env)
|
|
87
|
+
require "json"
|
|
88
|
+
body = JSON.parse(env["rack.input"].read.to_s) rescue {}
|
|
89
|
+
result = Jump.exchange(body["token"], body["device_name"])
|
|
90
|
+
return forbidden unless result
|
|
91
|
+
|
|
92
|
+
[201, jump_json_headers, [JSON.generate(result)]]
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def jump_manifest(env)
|
|
96
|
+
require "json"
|
|
97
|
+
bearer = env["HTTP_AUTHORIZATION"].to_s.delete_prefix("Bearer ").strip
|
|
98
|
+
return forbidden unless Jump.valid_bearer?(bearer)
|
|
99
|
+
|
|
100
|
+
[200, jump_json_headers, [JSON.generate(Jump.manifest_hash(Config.load(@root)))]]
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def forbidden
|
|
104
|
+
[403, jump_json_headers, ['{"error":"invalid or expired preview token"}']]
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def jump_json_headers
|
|
108
|
+
{ "content-type" => "application/json", "cache-control" => "no-store" }.merge(Jump::CORS_HEADERS)
|
|
109
|
+
end
|
|
110
|
+
|
|
64
111
|
def json(body)
|
|
65
112
|
[200, { "content-type" => "application/json", "cache-control" => cache_control }, [body]]
|
|
66
113
|
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "config"
|
|
4
|
+
require_relative "jump"
|
|
4
5
|
|
|
5
6
|
module Everywhere
|
|
6
7
|
# Serves the mobile shells' remote path configuration in Rails apps:
|
|
@@ -31,6 +32,7 @@ module Everywhere
|
|
|
31
32
|
|
|
32
33
|
os = params[:platform_v1].delete_suffix("_v1")
|
|
33
34
|
tabs = Everywhere.resolve_tabs(config.tabs_for(os), request)
|
|
35
|
+
tabs = Jump.exit_tabs(config, os, tabs) if Jump.enabled?
|
|
34
36
|
render json: config.path_configuration_hash(os, tabs: tabs)
|
|
35
37
|
end
|
|
36
38
|
|
|
@@ -52,6 +54,43 @@ module Everywhere
|
|
|
52
54
|
render json: json
|
|
53
55
|
end
|
|
54
56
|
|
|
57
|
+
# Jump preview endpoints (spec: platform/docs/jump.md §3). 404 unless
|
|
58
|
+
# `every preview` is running (EVERYWHERE_JUMP_TOKEN set), so nothing is
|
|
59
|
+
# reachable on a normal deploy. Always cross-origin (the connect page
|
|
60
|
+
# lives on the Jump site), hence the CORS headers + preflight.
|
|
61
|
+
def jump_session
|
|
62
|
+
return head(:not_found) unless Jump.enabled?
|
|
63
|
+
|
|
64
|
+
apply_jump_headers
|
|
65
|
+
return head(:no_content) if request.method == "OPTIONS"
|
|
66
|
+
|
|
67
|
+
result = Jump.exchange(params[:token], params[:device_name])
|
|
68
|
+
return head(:forbidden) unless result
|
|
69
|
+
|
|
70
|
+
render json: result, status: :created
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def jump_manifest
|
|
74
|
+
return head(:not_found) unless Jump.enabled?
|
|
75
|
+
|
|
76
|
+
apply_jump_headers
|
|
77
|
+
return head(:no_content) if request.method == "OPTIONS"
|
|
78
|
+
|
|
79
|
+
bearer = request.headers["Authorization"].to_s.delete_prefix("Bearer ").strip
|
|
80
|
+
return head(:forbidden) unless Jump.valid_bearer?(bearer)
|
|
81
|
+
|
|
82
|
+
render json: Jump.manifest_hash(Config.load(::Rails.root.to_s))
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# GET /everywhere/jump/leave — the way back OUT of a Jump preview. Served
|
|
86
|
+
# unconditionally (unlike the endpoints above): once Jump re-roots onto a
|
|
87
|
+
# previewed app, this page is its only path home, preview session or not.
|
|
88
|
+
def jump_leave
|
|
89
|
+
response.headers["cache-control"] = "no-store"
|
|
90
|
+
render html: Everywhere.mobile_jump_leave_html.html_safe,
|
|
91
|
+
layout: false, content_type: "text/html"
|
|
92
|
+
end
|
|
93
|
+
|
|
55
94
|
# GET /everywhere/reset?to=/path — the native "reset the app" page. Auth
|
|
56
95
|
# flows redirect the shell here so it rebuilds cleanly before landing on
|
|
57
96
|
# `to`. Never cached (it must run every time).
|
|
@@ -60,5 +99,12 @@ module Everywhere
|
|
|
60
99
|
render html: Everywhere.mobile_reset_html(params[:to]).html_safe,
|
|
61
100
|
layout: false, content_type: "text/html"
|
|
62
101
|
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
def apply_jump_headers
|
|
106
|
+
response.headers["cache-control"] = "no-store"
|
|
107
|
+
Jump::CORS_HEADERS.each { |key, value| response.headers[key] = value }
|
|
108
|
+
end
|
|
63
109
|
end
|
|
64
110
|
end
|