ruby_everywhere 0.0.1 → 0.1.1
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 +267 -0
- data/lib/everywhere/commands/dev.rb +61 -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 +142 -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/paths.rb +35 -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
- data/support/shell/splash/index.html +40 -0
- data/support/shell/src-tauri/Cargo.lock +5446 -0
- data/support/shell/src-tauri/Cargo.toml +19 -0
- data/support/shell/src-tauri/build.rs +3 -0
- data/support/shell/src-tauri/capabilities/default.json +22 -0
- data/support/shell/src-tauri/gen/schemas/acl-manifests.json +1 -0
- data/support/shell/src-tauri/gen/schemas/capabilities.json +1 -0
- data/support/shell/src-tauri/gen/schemas/desktop-schema.json +2634 -0
- data/support/shell/src-tauri/gen/schemas/macOS-schema.json +2634 -0
- data/support/shell/src-tauri/icons/icon.png +0 -0
- data/support/shell/src-tauri/src/main.rs +723 -0
- data/support/shell/src-tauri/tauri.conf.json +20 -0
- metadata +80 -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,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Everywhere
|
|
4
|
+
# Filesystem locations the CLI resolves relative to the gem itself, so
|
|
5
|
+
# `every dev`/`build`/`release` work in any project the gem is installed into.
|
|
6
|
+
module Paths
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
# The gem's own root — the dir holding lib/, exe/, support/. Correct whether
|
|
10
|
+
# the code runs from a git checkout or an installed gem.
|
|
11
|
+
def gem_root
|
|
12
|
+
File.expand_path("../..", __dir__)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# The generic Tauri shell vendored inside the gem: support/shell/ holds the
|
|
16
|
+
# src-tauri app and the splash/ dir it serves. This is the shell the CLI
|
|
17
|
+
# uses unless the caller overrides it with --shell-dir.
|
|
18
|
+
def bundled_shell_dir
|
|
19
|
+
File.join(gem_root, "support", "shell", "src-tauri")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Absolute path to the shell's src-tauri directory, or nil if the vendored
|
|
23
|
+
# copy is missing (a corrupt/partial install).
|
|
24
|
+
def shell_dir
|
|
25
|
+
bundled_shell_dir if File.exist?(File.join(bundled_shell_dir, "tauri.conf.json"))
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Like #shell_dir but aborts with a helpful message when the shell is
|
|
29
|
+
# missing. The --shell-dir hint covers monorepo dev pointed at build-runner.
|
|
30
|
+
def shell_dir!
|
|
31
|
+
shell_dir or UI.die!("couldn't find the bundled shell at #{bundled_shell_dir}; " \
|
|
32
|
+
"reinstall ruby_everywhere or pass --shell-dir")
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -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
|