ruby_everywhere 0.0.1 → 0.1.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 +6 -0
- data/exe/rbe +6 -0
- data/lib/everywhere/boot.rb +138 -0
- data/lib/everywhere/cli.rb +44 -0
- data/lib/everywhere/commands/build.rb +272 -0
- data/lib/everywhere/commands/dev.rb +66 -0
- data/lib/everywhere/commands/doctor.rb +54 -0
- data/lib/everywhere/commands/install.rb +264 -0
- data/lib/everywhere/commands/platform/auth_status.rb +42 -0
- data/lib/everywhere/commands/platform/build.rb +181 -0
- data/lib/everywhere/commands/platform/login.rb +81 -0
- data/lib/everywhere/commands/platform/logout.rb +34 -0
- data/lib/everywhere/commands/platform/runner.rb +224 -0
- data/lib/everywhere/commands/release.rb +143 -0
- data/lib/everywhere/config.rb +188 -0
- data/lib/everywhere/database.rb +89 -0
- data/lib/everywhere/framework.rb +197 -0
- data/lib/everywhere/ignore.rb +85 -0
- data/lib/everywhere/javascript/bridge.js +174 -0
- data/lib/everywhere/platform/client.rb +125 -0
- data/lib/everywhere/platform/credentials.rb +71 -0
- data/lib/everywhere/platform/snapshot.rb +48 -0
- data/lib/everywhere/png.rb +110 -0
- data/lib/everywhere/receipt.rb +185 -0
- data/lib/everywhere/shellout.rb +46 -0
- data/lib/everywhere/ui.rb +37 -0
- data/lib/everywhere/version.rb +11 -0
- data/lib/everywhere.rb +50 -0
- data/lib/ruby_everywhere.rb +2 -3
- data/support/github/build.yml +85 -0
- data/support/macos/entitlements.plist +23 -0
- data/support/macos/notarize.sh +123 -0
- metadata +67 -7
- data/README.md +0 -11
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Everywhere
|
|
4
|
+
# Framework adapters keep both the CLI (dev/build) and the packaged runtime
|
|
5
|
+
# (boot) framework-agnostic. Each adapter owns two sides of one framework:
|
|
6
|
+
#
|
|
7
|
+
# CLI side — dev_command / precompile_* / cleanup_command (build.rb, dev.rb)
|
|
8
|
+
# runtime side — prepare_runtime_env / boot! (boot.rb, inside the binary)
|
|
9
|
+
#
|
|
10
|
+
# Rails keeps its own boot path (Rails::Command server, multi-db prepare_all).
|
|
11
|
+
# Sinatra and Hanami are plain Rack apps and share RackFramework's boot: load
|
|
12
|
+
# config.ru, prepare the database, serve it under Puma bound to localhost.
|
|
13
|
+
class Framework
|
|
14
|
+
def self.detect(root = Dir.pwd)
|
|
15
|
+
return Rails.new(root) if rails?(root)
|
|
16
|
+
return Hanami.new(root) if hanami?(root)
|
|
17
|
+
return Sinatra.new(root) if sinatra?(root)
|
|
18
|
+
|
|
19
|
+
raise Error, "No supported framework detected in #{root} (looked for Rails, Hanami, Sinatra)"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.rails?(root)
|
|
23
|
+
File.exist?(File.join(root, "config", "application.rb")) &&
|
|
24
|
+
gemfile_mentions?(root, "rails")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.hanami?(root)
|
|
28
|
+
File.exist?(File.join(root, "config", "app.rb")) && gemfile_mentions?(root, "hanami")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.sinatra?(root)
|
|
32
|
+
File.exist?(File.join(root, "config.ru")) && gemfile_mentions?(root, "sinatra")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.gemfile_mentions?(root, gem_name)
|
|
36
|
+
gemfile = File.join(root, "Gemfile")
|
|
37
|
+
File.exist?(gemfile) && File.read(gemfile).match?(/gem ["']#{Regexp.escape(gem_name)}/)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
attr_reader :root
|
|
41
|
+
|
|
42
|
+
def initialize(root)
|
|
43
|
+
@root = File.expand_path(root)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# ---- runtime (inside the packaged binary) --------------------------------
|
|
47
|
+
|
|
48
|
+
# Set framework-specific env before the app loads. Called by Boot.
|
|
49
|
+
def prepare_runtime_env; end
|
|
50
|
+
|
|
51
|
+
# Load the app, prepare its database, and start the server bound to
|
|
52
|
+
# 127.0.0.1:port. Blocks. Implemented per framework.
|
|
53
|
+
def boot!(port:)
|
|
54
|
+
raise NotImplementedError, "#{name} has no packaged boot path yet"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Build the Rack app from config.ru. Rack 3 returns the app; Rack 2 returned
|
|
58
|
+
# [app, options].
|
|
59
|
+
def load_rack_app
|
|
60
|
+
require "rack"
|
|
61
|
+
built = ::Rack::Builder.parse_file(File.join(root, "config.ru"))
|
|
62
|
+
built.is_a?(Array) ? built.first : built
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# ---- install (CLI side) --------------------------------------------------
|
|
66
|
+
|
|
67
|
+
# The native_boot.rb entry point `every install` writes. Frameworks differ
|
|
68
|
+
# in which env they set and how the bundle is loaded before the gem.
|
|
69
|
+
def boot_stub
|
|
70
|
+
render_boot_stub(env_exports, "require \"bundler/setup\"")
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Env each framework needs set before the app loads, beyond NATIVE_PACKAGED.
|
|
74
|
+
def env_exports = {}
|
|
75
|
+
|
|
76
|
+
def render_boot_stub(env, setup_line)
|
|
77
|
+
exports = ({ "NATIVE_PACKAGED" => "1" }.merge(env))
|
|
78
|
+
.map { |k, v| "ENV[#{k.inspect}] ||= #{v.inspect}" }.join("\n")
|
|
79
|
+
<<~RUBY
|
|
80
|
+
#!/usr/bin/env ruby
|
|
81
|
+
# frozen_string_literal: true
|
|
82
|
+
|
|
83
|
+
# RubyEverywhere packaged-app entry point (generated by `every install`).
|
|
84
|
+
# Keep this thin: the real boot lives in the ruby_everywhere gem.
|
|
85
|
+
#{exports}
|
|
86
|
+
|
|
87
|
+
#{setup_line}
|
|
88
|
+
require "ruby_everywhere"
|
|
89
|
+
|
|
90
|
+
Everywhere.boot!(root: __dir__)
|
|
91
|
+
RUBY
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Serve a Rack app under Puma on localhost. Keep-alives are disabled: WebKit
|
|
95
|
+
# (the webview) reuses connections Puma has already closed, which shows up as
|
|
96
|
+
# intermittent "network connection was lost" asset failures.
|
|
97
|
+
def serve_rack(app, port:)
|
|
98
|
+
require "puma"
|
|
99
|
+
require "puma/configuration"
|
|
100
|
+
require "puma/launcher"
|
|
101
|
+
|
|
102
|
+
configuration = ::Puma::Configuration.new do |c|
|
|
103
|
+
c.bind "tcp://127.0.0.1:#{port}"
|
|
104
|
+
c.app app
|
|
105
|
+
c.environment ENV.fetch("RACK_ENV", "production")
|
|
106
|
+
c.workers 0
|
|
107
|
+
c.threads 1, 5
|
|
108
|
+
c.enable_keep_alives false if c.respond_to?(:enable_keep_alives)
|
|
109
|
+
end
|
|
110
|
+
::Puma::Launcher.new(configuration).run
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# ---- build/dev (CLI side) ------------------------------------------------
|
|
114
|
+
|
|
115
|
+
class Rails < Framework
|
|
116
|
+
def name = "rails"
|
|
117
|
+
|
|
118
|
+
# CLI
|
|
119
|
+
def dev_command
|
|
120
|
+
File.exist?(File.join(root, "bin", "dev")) ? "bin/dev" : "bundle exec rails server"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def precompile_env
|
|
124
|
+
{ "RAILS_ENV" => "production", "SECRET_KEY_BASE" => "precompile-only" }
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def precompile_command = "bin/rails assets:precompile"
|
|
128
|
+
|
|
129
|
+
def cleanup_command = "bin/rails assets:clobber"
|
|
130
|
+
|
|
131
|
+
# Runtime
|
|
132
|
+
def prepare_runtime_env
|
|
133
|
+
ENV["RAILS_ENV"] ||= "production"
|
|
134
|
+
ENV["SOLID_QUEUE_IN_PUMA"] ||= "1"
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def boot!(port:)
|
|
138
|
+
# Normally defined by bin/rails; the server command requires it.
|
|
139
|
+
::Object.const_set(:APP_PATH, File.join(root, "config/application")) unless defined?(::APP_PATH)
|
|
140
|
+
require File.join(root, "config/environment")
|
|
141
|
+
Everywhere::Database.prepare!(root)
|
|
142
|
+
|
|
143
|
+
require "rails/command"
|
|
144
|
+
::Rails::Command.invoke :server, ["--binding=127.0.0.1", "--port=#{port}"]
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Install
|
|
148
|
+
def env_exports = { "RAILS_ENV" => "production" }
|
|
149
|
+
|
|
150
|
+
# Rails loads its bundle through config/boot.rb (with bootsnap guarded).
|
|
151
|
+
def boot_stub
|
|
152
|
+
render_boot_stub(env_exports, "require_relative \"config/boot\"")
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Plain Rack frameworks (Sinatra, Hanami) — same packaged boot path.
|
|
157
|
+
class RackFramework < Framework
|
|
158
|
+
def prepare_runtime_env
|
|
159
|
+
ENV["RACK_ENV"] ||= "production"
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def boot!(port:)
|
|
163
|
+
app = load_rack_app # loads app files (and any Everywhere.configure)
|
|
164
|
+
Everywhere::Database.prepare!(root)
|
|
165
|
+
serve_rack(app, port: port)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def precompile_env = {}
|
|
169
|
+
def cleanup_command = "true"
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
class Sinatra < RackFramework
|
|
173
|
+
def name = "sinatra"
|
|
174
|
+
|
|
175
|
+
def dev_command
|
|
176
|
+
File.exist?(File.join(root, "bin", "dev")) ? "bin/dev" : "bundle exec rackup --host 127.0.0.1"
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# No asset pipeline by default; nothing to precompile.
|
|
180
|
+
def precompile_command = "true"
|
|
181
|
+
|
|
182
|
+
def env_exports = { "RACK_ENV" => "production" }
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
class Hanami < RackFramework
|
|
186
|
+
def name = "hanami"
|
|
187
|
+
|
|
188
|
+
def dev_command = "bundle exec hanami dev"
|
|
189
|
+
|
|
190
|
+
def precompile_command = "bundle exec hanami assets compile"
|
|
191
|
+
|
|
192
|
+
def env_exports = { "HANAMI_ENV" => "production", "RACK_ENV" => "production" }
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
class Error < StandardError; end
|
|
197
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Everywhere
|
|
4
|
+
# Shared source-exclusion rules, read from `.everywhereignore` (dockerignore
|
|
5
|
+
# style) when present, else a sensible default. ONE source of truth so the
|
|
6
|
+
# build receipt's source checksum (Receipt) and the Platform snapshot
|
|
7
|
+
# (`every platform build`, later) describe exactly the same set of files — a
|
|
8
|
+
# checksum that didn't match what actually ships would be worse than none.
|
|
9
|
+
#
|
|
10
|
+
# Pattern semantics are a pragmatic subset of .dockerignore/.gitignore:
|
|
11
|
+
# dist → a path segment named "dist", anywhere
|
|
12
|
+
# vendor/bundle → that exact path (and everything under it)
|
|
13
|
+
# *.log → glob against the full path and the basename
|
|
14
|
+
# !keep/this.log → NEGATION: re-include a file an earlier pattern excluded
|
|
15
|
+
#
|
|
16
|
+
# Rules are evaluated top to bottom and the LAST one that matches a path wins
|
|
17
|
+
# (dockerignore semantics), so ordering matters: put `!` re-includes after the
|
|
18
|
+
# broad exclude they carve out of.
|
|
19
|
+
class Ignore
|
|
20
|
+
FILE = ".everywhereignore"
|
|
21
|
+
FLAGS = File::FNM_PATHNAME | File::FNM_DOTMATCH
|
|
22
|
+
|
|
23
|
+
# Build products, caches, VCS, local state, and secrets. Excluding `.env*`
|
|
24
|
+
# and the key/cert family keeps signing material out of both the checksum
|
|
25
|
+
# and any future snapshot.
|
|
26
|
+
DEFAULTS = %w[
|
|
27
|
+
dist tmp log node_modules .git storage .bundle vendor/bundle
|
|
28
|
+
coverage public/assets public/packs .DS_Store .env* *.log
|
|
29
|
+
*.key *.pem *.crt *.p12 *.mobileprovision
|
|
30
|
+
].freeze
|
|
31
|
+
|
|
32
|
+
def self.for(root)
|
|
33
|
+
path = File.join(root, FILE)
|
|
34
|
+
source = File.exist?(path) ? File.read(path) : DEFAULTS.join("\n")
|
|
35
|
+
new(parse(source))
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# → [{ negated:, pattern: }, ...] in file order. Blank lines and whole-line
|
|
39
|
+
# `#` comments are dropped; a leading `!` marks a re-include.
|
|
40
|
+
def self.parse(text)
|
|
41
|
+
text.lines.filter_map do |raw|
|
|
42
|
+
line = raw.chomp
|
|
43
|
+
next if line.strip.empty? || line.lstrip.start_with?("#")
|
|
44
|
+
|
|
45
|
+
negated = line.start_with?("!")
|
|
46
|
+
pattern = (negated ? line[1..] : line).strip.sub(%r{\A/}, "").chomp("/")
|
|
47
|
+
{ negated: negated, pattern: pattern } unless pattern.empty?
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
attr_reader :rules
|
|
52
|
+
|
|
53
|
+
def initialize(rules)
|
|
54
|
+
@rules = rules
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Every non-ignored regular file under root, as sorted root-relative paths.
|
|
58
|
+
def files(root)
|
|
59
|
+
Dir.glob("**/*", File::FNM_DOTMATCH, base: root)
|
|
60
|
+
.reject { |rel| ignored?(rel) }
|
|
61
|
+
.select { |rel| File.file?(File.join(root, rel)) }
|
|
62
|
+
.sort
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Last matching rule wins; a negated match re-includes. Default: kept.
|
|
66
|
+
def ignored?(rel)
|
|
67
|
+
base = rel.split("/").last
|
|
68
|
+
return true if [".", ".."].include?(base)
|
|
69
|
+
|
|
70
|
+
@rules.reduce(false) do |ignored, rule|
|
|
71
|
+
match?(rule[:pattern], rel, base) ? !rule[:negated] : ignored
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def match?(pat, rel, base)
|
|
78
|
+
rel == pat ||
|
|
79
|
+
rel.start_with?("#{pat}/") ||
|
|
80
|
+
(!pat.include?("/") && rel.split("/").include?(pat)) ||
|
|
81
|
+
File.fnmatch?(pat, rel, FLAGS) ||
|
|
82
|
+
File.fnmatch?(pat, base, FLAGS)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// @rubyeverywhere/bridge — one API for the browser, the RubyEverywhere desktop
|
|
2
|
+
// shell, and (soon) Hotwire Native mobile apps.
|
|
3
|
+
//
|
|
4
|
+
// App code writes to this surface only; platform differences live in the
|
|
5
|
+
// adapters below. Everything degrades: the same page works in a plain browser
|
|
6
|
+
// tab and gains native powers inside an app.
|
|
7
|
+
//
|
|
8
|
+
// import Everywhere from "@rubyeverywhere/bridge"
|
|
9
|
+
//
|
|
10
|
+
// Everywhere.platform // "desktop" | "mobile" | "browser"
|
|
11
|
+
// Everywhere.os // "macos" | "windows" | "linux" | "ios" | "android" | "chromeos" | "unknown"
|
|
12
|
+
// Everywhere.native // true inside a RubyEverywhere app
|
|
13
|
+
// Everywhere.notify({ title, body }) // native / web Notification / console
|
|
14
|
+
// Everywhere.confirm("Sure?") // Promise<boolean>, native dialog when possible
|
|
15
|
+
// Everywhere.on("menu", handler) // shell events; returns unsubscribe fn
|
|
16
|
+
// Everywhere.visit("/settings") // Turbo.visit with location fallback
|
|
17
|
+
|
|
18
|
+
const config =
|
|
19
|
+
(typeof window !== "undefined" && window.__EVERYWHERE_CONFIG__) || {}
|
|
20
|
+
|
|
21
|
+
function detectOS() {
|
|
22
|
+
if (config.os) return config.os // authoritative, injected by the desktop shell
|
|
23
|
+
if (typeof navigator === "undefined") return "unknown"
|
|
24
|
+
const ua = navigator.userAgent
|
|
25
|
+
if (/Hotwire Native iOS/i.test(ua)) return "ios"
|
|
26
|
+
if (/Hotwire Native Android/i.test(ua)) return "android"
|
|
27
|
+
if (/android/i.test(ua)) return "android"
|
|
28
|
+
if (/iPhone|iPad|iPod/.test(ua)) return "ios"
|
|
29
|
+
// iPadOS 13+ masquerades as a Mac; multitouch gives it away
|
|
30
|
+
if (/Mac/.test(ua) && navigator.maxTouchPoints > 1) return "ios"
|
|
31
|
+
if (/CrOS/.test(ua)) return "chromeos"
|
|
32
|
+
if (/Mac/.test(ua)) return "macos"
|
|
33
|
+
if (/Win/.test(ua)) return "windows"
|
|
34
|
+
if (/Linux/.test(ua)) return "linux"
|
|
35
|
+
return "unknown"
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function detectPlatform() {
|
|
39
|
+
if (typeof window === "undefined") return "browser"
|
|
40
|
+
if (window.__TAURI__) return "desktop"
|
|
41
|
+
if (/Hotwire Native/i.test(navigator.userAgent)) return "mobile"
|
|
42
|
+
return "browser"
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// --- desktop: the Tauri-based RubyEverywhere shell -------------------------
|
|
46
|
+
|
|
47
|
+
const desktop = {
|
|
48
|
+
notify({ title, body }) {
|
|
49
|
+
return window.__TAURI__.event.emit("everywhere:notify", { title, body })
|
|
50
|
+
},
|
|
51
|
+
|
|
52
|
+
confirm(message) {
|
|
53
|
+
return window.__TAURI__.dialog.ask(message, {
|
|
54
|
+
title: config.name || "Confirm",
|
|
55
|
+
kind: "warning"
|
|
56
|
+
})
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
on(event, handler) {
|
|
60
|
+
let unlisten = null
|
|
61
|
+
let active = true
|
|
62
|
+
window.__TAURI__.event
|
|
63
|
+
.listen(`everywhere:${event}`, (e) => handler(e.payload))
|
|
64
|
+
.then((fn) => (active ? (unlisten = fn) : fn()))
|
|
65
|
+
return () => {
|
|
66
|
+
active = false
|
|
67
|
+
if (unlisten) unlisten()
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
clipboardWrite(text) {
|
|
72
|
+
return window.__TAURI__.clipboardManager.writeText(text)
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
clipboardRead() {
|
|
76
|
+
return window.__TAURI__.clipboardManager.readText()
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// --- browser: honest web fallbacks ------------------------------------------
|
|
81
|
+
|
|
82
|
+
const browser = {
|
|
83
|
+
async notify({ title, body }) {
|
|
84
|
+
if (!("Notification" in window)) {
|
|
85
|
+
console.log(`[everywhere] notify: ${title}${body ? ` — ${body}` : ""}`)
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
if (Notification.permission === "default") {
|
|
89
|
+
await Notification.requestPermission()
|
|
90
|
+
}
|
|
91
|
+
if (Notification.permission === "granted") {
|
|
92
|
+
new Notification(title, { body })
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
confirm(message) {
|
|
97
|
+
return Promise.resolve(window.confirm(message))
|
|
98
|
+
},
|
|
99
|
+
|
|
100
|
+
on(event, handler) {
|
|
101
|
+
const listener = (e) => handler(e.detail)
|
|
102
|
+
document.addEventListener(`everywhere:${event}`, listener)
|
|
103
|
+
return () => document.removeEventListener(`everywhere:${event}`, listener)
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
async clipboardWrite(text) {
|
|
107
|
+
if (navigator.clipboard) return navigator.clipboard.writeText(text)
|
|
108
|
+
console.log("[everywhere] clipboard unavailable (insecure context?)")
|
|
109
|
+
},
|
|
110
|
+
|
|
111
|
+
async clipboardRead() {
|
|
112
|
+
// Browsers gate reads hard (permission prompt, secure context, focus).
|
|
113
|
+
if (navigator.clipboard && navigator.clipboard.readText) {
|
|
114
|
+
try { return await navigator.clipboard.readText() } catch (_) { return null }
|
|
115
|
+
}
|
|
116
|
+
return null
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// --- mobile: Hotwire Native (stub) ------------------------------------------
|
|
121
|
+
// Detection is real (Hotwire Native apps set their user agent); behavior falls
|
|
122
|
+
// back to the browser adapter until the bridge-component adapter lands. The
|
|
123
|
+
// native webview implements confirm() properly, so that fallback is already
|
|
124
|
+
// correct on mobile.
|
|
125
|
+
|
|
126
|
+
const mobile = {
|
|
127
|
+
...browser
|
|
128
|
+
// TODO: notify via a Hotwire Native bridge component ("everywhere--notification")
|
|
129
|
+
// TODO: menu/tab events dispatched as document CustomEvents ("everywhere:menu")
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// --- public surface ----------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
const adapters = { desktop, browser, mobile }
|
|
135
|
+
const platform = detectPlatform()
|
|
136
|
+
const os = detectOS()
|
|
137
|
+
const adapter = adapters[platform]
|
|
138
|
+
|
|
139
|
+
export const Everywhere = {
|
|
140
|
+
platform,
|
|
141
|
+
os,
|
|
142
|
+
|
|
143
|
+
get native() {
|
|
144
|
+
return platform !== "browser"
|
|
145
|
+
},
|
|
146
|
+
|
|
147
|
+
notify(options = {}) {
|
|
148
|
+
return adapter.notify({ title: document.title, body: "", ...options })
|
|
149
|
+
},
|
|
150
|
+
|
|
151
|
+
confirm(message) {
|
|
152
|
+
return adapter.confirm(message)
|
|
153
|
+
},
|
|
154
|
+
|
|
155
|
+
on(event, handler) {
|
|
156
|
+
return adapter.on(event, handler)
|
|
157
|
+
},
|
|
158
|
+
|
|
159
|
+
visit(path) {
|
|
160
|
+
if (window.Turbo) window.Turbo.visit(path)
|
|
161
|
+
else window.location.assign(path)
|
|
162
|
+
},
|
|
163
|
+
|
|
164
|
+
clipboard: {
|
|
165
|
+
write(text) {
|
|
166
|
+
return adapter.clipboardWrite(text)
|
|
167
|
+
},
|
|
168
|
+
read() {
|
|
169
|
+
return adapter.clipboardRead()
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export default Everywhere
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
require "uri"
|
|
6
|
+
require_relative "../version"
|
|
7
|
+
|
|
8
|
+
module Everywhere
|
|
9
|
+
# Talks to the RubyEverywhere Platform. Small JSON-over-HTTP wrapper — no gem
|
|
10
|
+
# dependency, so it works inside the packaged CLI too.
|
|
11
|
+
module Platform
|
|
12
|
+
# Production Platform API. rubyeverywhere.com is the marketing/docs site;
|
|
13
|
+
# the Platform lives on the `platform.` subdomain. Override for dev with
|
|
14
|
+
# --url or $EVERYWHERE_PLATFORM_URL (e.g. http://localhost:3000).
|
|
15
|
+
DEFAULT_URL = "https://platform.rubyeverywhere.com"
|
|
16
|
+
|
|
17
|
+
# Best-effort browser open; returns false (never raises) if it can't.
|
|
18
|
+
def self.open_url(url)
|
|
19
|
+
cmd =
|
|
20
|
+
if RUBY_PLATFORM.include?("darwin") then ["open", url]
|
|
21
|
+
elsif RUBY_PLATFORM.match?(/mingw|mswin/) then ["cmd", "/c", "start", "", url]
|
|
22
|
+
else ["xdg-open", url]
|
|
23
|
+
end
|
|
24
|
+
system(*cmd, out: File::NULL, err: File::NULL)
|
|
25
|
+
rescue StandardError
|
|
26
|
+
false
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
class Client
|
|
30
|
+
def self.base_url(explicit = nil)
|
|
31
|
+
(explicit || ENV["EVERYWHERE_PLATFORM_URL"] || DEFAULT_URL).chomp("/")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
attr_reader :base_url
|
|
35
|
+
|
|
36
|
+
def initialize(base_url, token: nil)
|
|
37
|
+
@base_url = base_url
|
|
38
|
+
@base = URI.parse("#{base_url}/")
|
|
39
|
+
@token = token
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def post(path, body = {}) = request(Net::HTTP::Post, path, body)
|
|
43
|
+
def patch(path, body = {}) = request(Net::HTTP::Patch, path, body)
|
|
44
|
+
def get(path) = request(Net::HTTP::Get, path)
|
|
45
|
+
def delete(path) = request(Net::HTTP::Delete, path)
|
|
46
|
+
|
|
47
|
+
# Stream a file with a raw PUT (Active Storage direct upload). `url` is
|
|
48
|
+
# absolute (from the reservation); `headers` come from the reservation too.
|
|
49
|
+
# Returns the Net::HTTP response.
|
|
50
|
+
def put_file(url, path, headers: {})
|
|
51
|
+
uri = URI.parse(url)
|
|
52
|
+
req = Net::HTTP::Put.new(uri)
|
|
53
|
+
headers.each { |k, v| req[k] = v }
|
|
54
|
+
req["Content-Length"] = File.size(path).to_s
|
|
55
|
+
File.open(path, "rb") do |io|
|
|
56
|
+
req.body_stream = io
|
|
57
|
+
http(uri) { |conn| conn.request(req) }
|
|
58
|
+
end
|
|
59
|
+
rescue SocketError, Errno::ECONNREFUSED, Errno::ETIMEDOUT => e
|
|
60
|
+
raise Error, "upload to #{uri.host} failed (#{e.class})"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# GET a (possibly redirecting, signed) URL and stream it to dest. Sends the
|
|
64
|
+
# bearer token only to the platform host (the first hop when the URL is a
|
|
65
|
+
# /runner or /cli endpoint); a redirect to a signed blob/S3 URL is followed
|
|
66
|
+
# without it.
|
|
67
|
+
def download(url, dest, redirects: 5)
|
|
68
|
+
uri = URI.parse(url)
|
|
69
|
+
req = Net::HTTP::Get.new(uri)
|
|
70
|
+
req["Authorization"] = "Bearer #{@token}" if @token && uri.host == @base.host && uri.port == @base.port
|
|
71
|
+
http(uri) do |conn|
|
|
72
|
+
conn.request(req) do |res|
|
|
73
|
+
case res
|
|
74
|
+
when Net::HTTPRedirection
|
|
75
|
+
raise Error, "too many redirects downloading #{url}" if redirects <= 0
|
|
76
|
+
|
|
77
|
+
return download(res["location"], dest, redirects: redirects - 1)
|
|
78
|
+
when Net::HTTPSuccess
|
|
79
|
+
File.open(dest, "wb") { |f| res.read_body { |chunk| f.write(chunk) } }
|
|
80
|
+
else
|
|
81
|
+
raise Error, "download failed (HTTP #{res.code})"
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
dest
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def http(uri, &block)
|
|
91
|
+
Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https", &block)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def request(klass, path, body = nil)
|
|
95
|
+
uri = URI.join(@base, path.sub(%r{\A/}, ""))
|
|
96
|
+
req = klass.new(uri)
|
|
97
|
+
req["Accept"] = "application/json"
|
|
98
|
+
req["User-Agent"] = "every/#{Everywhere::VERSION}"
|
|
99
|
+
req["Authorization"] = "Bearer #{@token}" if @token
|
|
100
|
+
if body
|
|
101
|
+
req["Content-Type"] = "application/json"
|
|
102
|
+
req.body = JSON.generate(body)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") { |http| http.request(req) }
|
|
106
|
+
Response.new(res.code.to_i, parse(res.body))
|
|
107
|
+
rescue SocketError, Errno::ECONNREFUSED, Errno::ETIMEDOUT => e
|
|
108
|
+
raise Error, "couldn't reach the Platform at #{@base_url} (#{e.class})"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def parse(body)
|
|
112
|
+
return {} if body.to_s.empty?
|
|
113
|
+
|
|
114
|
+
JSON.parse(body)
|
|
115
|
+
rescue JSON::ParserError
|
|
116
|
+
{}
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
Response = Struct.new(:status, :body) do
|
|
120
|
+
def ok? = status.between?(200, 299)
|
|
121
|
+
def unauthorized? = status == 401
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module Everywhere
|
|
7
|
+
module Platform
|
|
8
|
+
# Local credential store at ~/.config/everywhere/credentials.json (0600),
|
|
9
|
+
# keyed by Platform base URL so a dev localhost login and a production login
|
|
10
|
+
# coexist. Each entry holds an org-scoped token + the org it's bound to.
|
|
11
|
+
#
|
|
12
|
+
# { "https://platform.rubyeverywhere.com": {
|
|
13
|
+
# "token": "rbe_…",
|
|
14
|
+
# "organization": { "id": "org_…", "name": "Acme", "slug": "acme" } } }
|
|
15
|
+
class Credentials
|
|
16
|
+
def self.path
|
|
17
|
+
ENV["EVERYWHERE_CREDENTIALS"] || File.join(config_home, "everywhere", "credentials.json")
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.config_home
|
|
21
|
+
ENV["XDG_CONFIG_HOME"].to_s.empty? ? File.expand_path("~/.config") : ENV["XDG_CONFIG_HOME"]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.load
|
|
25
|
+
data =
|
|
26
|
+
if File.exist?(path)
|
|
27
|
+
JSON.parse(File.read(path)) rescue {}
|
|
28
|
+
else
|
|
29
|
+
{}
|
|
30
|
+
end
|
|
31
|
+
new(data.is_a?(Hash) ? data : {})
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def initialize(data)
|
|
35
|
+
@data = data
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def for(url)
|
|
39
|
+
@data[normalize(url)]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def logged_in?(url)
|
|
43
|
+
!self.for(url).nil?
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def set(url, entry)
|
|
47
|
+
@data[normalize(url)] = entry
|
|
48
|
+
save
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def delete(url)
|
|
52
|
+
removed = @data.delete(normalize(url))
|
|
53
|
+
save
|
|
54
|
+
removed
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def normalize(url) = url.to_s.chomp("/")
|
|
60
|
+
|
|
61
|
+
def save
|
|
62
|
+
path = self.class.path
|
|
63
|
+
dir = File.dirname(path)
|
|
64
|
+
FileUtils.mkdir_p(dir)
|
|
65
|
+
File.chmod(0o700, dir)
|
|
66
|
+
File.write(path, "#{JSON.pretty_generate(@data)}\n")
|
|
67
|
+
File.chmod(0o600, path)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rubygems/package"
|
|
4
|
+
require "zlib"
|
|
5
|
+
require "digest"
|
|
6
|
+
require_relative "../ignore"
|
|
7
|
+
|
|
8
|
+
module Everywhere
|
|
9
|
+
module Platform
|
|
10
|
+
# Packs the app's source into a .tar.gz for a Platform build, honoring
|
|
11
|
+
# .everywhereignore (so dist/, node_modules, .git, secrets never ship). Pure
|
|
12
|
+
# Ruby — no `tar` dependency — and streams file-by-file so a large tree
|
|
13
|
+
# doesn't blow up memory.
|
|
14
|
+
module Snapshot
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
# Writes root's non-ignored files to out_path (a .tar.gz). Returns the file
|
|
18
|
+
# count.
|
|
19
|
+
def create(root, out_path)
|
|
20
|
+
files = Everywhere::Ignore.for(root).files(root)
|
|
21
|
+
File.open(out_path, "wb") do |dest|
|
|
22
|
+
Zlib::GzipWriter.wrap(dest) do |gz|
|
|
23
|
+
Gem::Package::TarWriter.new(gz) do |tar|
|
|
24
|
+
files.each { |rel| add(tar, root, rel) }
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
files.size
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# base64 MD5 of a file (what Active Storage's direct upload verifies),
|
|
32
|
+
# computed in chunks so large snapshots don't load into memory.
|
|
33
|
+
def md5_base64(path)
|
|
34
|
+
digest = Digest::MD5.new
|
|
35
|
+
File.open(path, "rb") { |f| digest << f.read(1 << 20) until f.eof? }
|
|
36
|
+
digest.base64digest
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def add(tar, root, rel)
|
|
40
|
+
abs = File.join(root, rel)
|
|
41
|
+
stat = File.stat(abs)
|
|
42
|
+
tar.add_file_simple(rel, stat.mode & 0o777, stat.size) do |io|
|
|
43
|
+
File.open(abs, "rb") { |f| IO.copy_stream(f, io) }
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|