ruby_everywhere 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +99 -3
  3. data/bridge/README.md +70 -1
  4. data/bridge/everywhere/bridge.js +151 -0
  5. data/bridge/everywhere/native.css +61 -0
  6. data/lib/everywhere/agents_guide.rb +57 -0
  7. data/lib/everywhere/auth_handoff.rb +8 -2
  8. data/lib/everywhere/builders/desktop.rb +326 -0
  9. data/lib/everywhere/child_processes.rb +74 -0
  10. data/lib/everywhere/commands/build.rb +44 -5
  11. data/lib/everywhere/commands/dev.rb +426 -59
  12. data/lib/everywhere/commands/install.rb +93 -1
  13. data/lib/everywhere/commands/release.rb +2 -2
  14. data/lib/everywhere/config.rb +298 -4
  15. data/lib/everywhere/console.rb +117 -0
  16. data/lib/everywhere/desktop_assets.rb +150 -0
  17. data/lib/everywhere/dock/footer.rb +150 -0
  18. data/lib/everywhere/dock/screen.rb +114 -0
  19. data/lib/everywhere/dock/state.rb +59 -0
  20. data/lib/everywhere/dock.rb +238 -0
  21. data/lib/everywhere/emulator.rb +2 -2
  22. data/lib/everywhere/fatal.rb +20 -0
  23. data/lib/everywhere/line_pump.rb +89 -0
  24. data/lib/everywhere/log_filter.rb +37 -0
  25. data/lib/everywhere/native_helper.rb +38 -5
  26. data/lib/everywhere/paths.rb +62 -11
  27. data/lib/everywhere/relay.rb +77 -0
  28. data/lib/everywhere/shellout.rb +90 -15
  29. data/lib/everywhere/task_pool.rb +123 -0
  30. data/lib/everywhere/ui.rb +54 -11
  31. data/lib/everywhere/version.rb +1 -1
  32. data/support/agents/AGENTS.md +90 -0
  33. data/support/agents/frameworks/hanami-views.md +26 -0
  34. data/support/agents/frameworks/hanami.md +10 -0
  35. data/support/agents/frameworks/rails-views.md +98 -0
  36. data/support/agents/frameworks/rails.md +16 -0
  37. data/support/agents/frameworks/sinatra-views.md +24 -0
  38. data/support/agents/frameworks/sinatra.md +9 -0
  39. data/support/agents/modes/local.md +12 -0
  40. data/support/agents/modes/remote.md +14 -0
  41. data/support/desktop/README.md +121 -0
  42. data/support/{shell → desktop}/src-tauri/Cargo.lock +23 -0
  43. data/support/{shell → desktop}/src-tauri/Cargo.toml +19 -1
  44. data/support/{shell → desktop}/src-tauri/capabilities/default.json +7 -0
  45. data/support/desktop/src-tauri/gen/schemas/capabilities.json +1 -0
  46. data/support/desktop/src-tauri/src/extension_host.rs +53 -0
  47. data/support/desktop/src-tauri/src/extensions/mod.rs +39 -0
  48. data/support/{shell → desktop}/src-tauri/src/main.rs +355 -10
  49. data/support/{shell → desktop}/src-tauri/tauri.conf.json +2 -2
  50. data/support/{macos → release/macos}/notarize.sh +2 -2
  51. metadata +55 -22
  52. data/support/github/build.yml +0 -85
  53. data/support/shell/src-tauri/gen/schemas/capabilities.json +0 -1
  54. /data/support/{shell → desktop}/splash/index.html +0 -0
  55. /data/support/{shell → desktop}/src-tauri/build.rs +0 -0
  56. /data/support/{shell → desktop}/src-tauri/gen/schemas/acl-manifests.json +0 -0
  57. /data/support/{shell → desktop}/src-tauri/gen/schemas/desktop-schema.json +0 -0
  58. /data/support/{shell → desktop}/src-tauri/gen/schemas/macOS-schema.json +0 -0
  59. /data/support/{shell → desktop}/src-tauri/icons/icon.png +0 -0
  60. /data/support/{shell → desktop}/src-tauri/src/updater.rs +0 -0
  61. /data/support/{macos → release/macos}/entitlements.plist +0 -0
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Everywhere
6
+ # Compiles an app's native/desktop/assets/ tree into the flat assets/ folder
7
+ # the desktop shell reads at runtime — Resources/assets/ inside a packaged
8
+ # .app, or the source tree itself under `every dev` (NATIVE_ASSETS_DIR).
9
+ #
10
+ # Unlike iOS and Android there is no platform resource format to generate:
11
+ # macOS has no catalog for a Tauri bundle's loose resources, so "compiling"
12
+ # here means validating the names and copying. The filenames stay exactly as
13
+ # written, because the shell resolves variants itself (see assets_dir /
14
+ # asset_path in the shell's main.rs) — it is the one consumer, and it can read
15
+ # a suffix as cheaply as we can rewrite one.
16
+ #
17
+ # Filenames are the whole API, matching AssetCatalog: <name>[@2x|@3x][~dark].<ext>.
18
+ # Sub-directories organise the tree but do NOT namespace it — names are global,
19
+ # so assets/branding/logo.png is still "logo". That also means the destination
20
+ # can be flat, which is what keeps the Rust side a plain directory read.
21
+ #
22
+ # These assets are for the SHELL, not for your pages: window and tray icons,
23
+ # splash artwork, and anything extension Rust loads. Images your HTML
24
+ # references should keep coming from the asset pipeline, which already
25
+ # fingerprints and serves them.
26
+ module DesktopAssets
27
+ # What the shell can actually decode. Deliberately narrower than
28
+ # AssetCatalog's: these are handed to tauri::image::Image or read by
29
+ # extension Rust, and neither speaks PDF or SVG.
30
+ RASTER = %w[.png .jpg .jpeg].freeze
31
+ # Windows tray icons are conventionally .ico; it's passed through untouched
32
+ # for an extension that wants to hand one to the platform directly.
33
+ PASSTHROUGH = %w[.ico].freeze
34
+ EXTENSIONS = (RASTER + PASSTHROUGH).freeze
35
+ VECTOR = %w[.pdf .svg].freeze
36
+
37
+ # Names the shell gives meaning to on its own. Not reserved — providing one
38
+ # is exactly how you override a default — but worth naming here so the list
39
+ # has one home.
40
+ #
41
+ # tray the menu-bar / system-tray icon, drawn as-is
42
+ # tray-template same, but declared a macOS template image: black + alpha,
43
+ # recoloured by the system so it stays legible in a light
44
+ # menu bar, a dark one, and under a highlight. Wins over
45
+ # `tray` when both are present.
46
+ SHELL_NAMES = %w[tray tray-template].freeze
47
+
48
+ VALID_NAME = /\A[A-Za-z0-9_][A-Za-z0-9_.-]*\z/
49
+
50
+ class << self
51
+ # Copy every image under `source` into `dest`. Returns [names, errors];
52
+ # nothing is written when errors is non-empty, so a broken assets/ tree
53
+ # can't half-stamp a bundle.
54
+ #
55
+ # `dest` is regenerated wholesale — this module owns that directory, and
56
+ # an asset deleted from the app repo has to stop shipping.
57
+ def write!(source, dest)
58
+ files, errors = plan(source)
59
+ return [[], errors] unless errors.empty?
60
+
61
+ FileUtils.rm_rf(dest)
62
+ return [[], []] if files.empty?
63
+
64
+ FileUtils.mkdir_p(dest)
65
+ files.each { |file| FileUtils.cp(file[:path], File.join(dest, File.basename(file[:path]))) }
66
+
67
+ [files.map { |file| file[:name] }.uniq.sort, []]
68
+ end
69
+
70
+ private
71
+
72
+ # Errors are collected rather than raised so one `every build` reports
73
+ # every problem in the tree, not just the first.
74
+ def plan(source)
75
+ return [[], []] unless File.directory?(source)
76
+
77
+ errors = []
78
+ found = Dir.glob(File.join(source, "**", "*"))
79
+ .select { |path| File.file?(path) && !File.basename(path).start_with?(".") }
80
+ .sort
81
+ images, other = found.partition { |path| EXTENSIONS.include?(File.extname(path).downcase) }
82
+
83
+ other.each { |path| errors << unsupported_error(path, source) }
84
+ files = images.filter_map { |path| variant(path, source, errors) }
85
+
86
+ files.group_by { |file| file[:name] }.each do |name, group|
87
+ dirs = group.map { |file| file[:dir] }.uniq
88
+ if dirs.length > 1
89
+ errors << "#{name}: same asset name in #{dirs.map { |dir| label(dir) }.join(" and ")} — " \
90
+ "names are global (the shell reads one flat folder), so rename one"
91
+ end
92
+ group.group_by { |file| [file[:scale], file[:dark]] }.each_value do |clash|
93
+ next if clash.length < 2
94
+
95
+ errors << "#{name}: #{clash.map { |file| file[:relative] }.join(" and ")} both claim the same slot"
96
+ end
97
+ end
98
+
99
+ [files, errors]
100
+ end
101
+
102
+ # A vector gets its own message: "unsupported image type" reads like the
103
+ # file is broken, when the real answer is a one-line export.
104
+ def unsupported_error(path, source)
105
+ relative = relative(path, source)
106
+ if VECTOR.include?(File.extname(path).downcase)
107
+ "#{relative}: the desktop shell can't rasterize vectors — export a PNG " \
108
+ "(at @2x, so it stays sharp on a Retina display)"
109
+ else
110
+ "#{relative}: unsupported image type — use #{EXTENSIONS.join(", ")}"
111
+ end
112
+ end
113
+
114
+ def variant(path, source, errors)
115
+ relative = relative(path, source)
116
+ name, scale, dark = parse(File.basename(path, File.extname(path)))
117
+
118
+ unless name.match?(VALID_NAME)
119
+ errors << "#{relative}: #{name.inspect} isn't a usable asset name — use letters, digits, _, - and ."
120
+ return nil
121
+ end
122
+
123
+ { name: name, dir: File.dirname(relative), scale: scale, dark: dark, path: path, relative: relative }
124
+ end
125
+
126
+ # Trailing @2x/@3x and ~dark markers, in either order, off the stem.
127
+ # Identical to AssetCatalog#parse on purpose: one filename convention
128
+ # across all three platforms is the whole point of the suffix API.
129
+ def parse(base)
130
+ name = base.dup
131
+ scale = nil
132
+ dark = false
133
+ loop do
134
+ if name.sub!(/~dark\z/, "")
135
+ dark = true
136
+ elsif name.sub!(/@([23])x\z/, "")
137
+ scale = "#{Regexp.last_match(1)}x"
138
+ else
139
+ break
140
+ end
141
+ end
142
+ [name, scale, dark]
143
+ end
144
+
145
+ def label(dir) = dir == "." ? "assets/" : "assets/#{dir}/"
146
+
147
+ def relative(path, source) = path.delete_prefix("#{source}/")
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../ui"
4
+
5
+ module Everywhere
6
+ class Dock
7
+ # Renders the pinned footer. PURE: state in, array of strings out. No IO, no
8
+ # clock, no terminal — every layout decision here is testable with plain
9
+ # string assertions.
10
+ #
11
+ # Composition rule: build every segment as PLAIN text, measure it, truncate,
12
+ # and only then paint. Measuring or cutting a string that already contains
13
+ # SGR codes is how you end up slicing through "\e[32" and corrupting the
14
+ # terminal, and it makes the width math quietly wrong under NO_COLOR.
15
+ module Footer
16
+ # Every state gets its own glyph, so the footer stays fully legible with
17
+ # NO_COLOR — color is redundant reinforcement, never the only signal.
18
+ # A nil glyph means "use the spinner frame".
19
+ GLYPHS = {
20
+ idle: ["○", :gray],
21
+ booting: [nil, :cyan],
22
+ building: [nil, :cyan],
23
+ installing: [nil, :cyan],
24
+ running: ["●", :green],
25
+ failed: ["✗", :red],
26
+ stopped: ["!", :yellow]
27
+ }.freeze
28
+
29
+ GAP = " "
30
+ KEY_GAP = " · "
31
+ ELLIPSIS = "…"
32
+
33
+ module_function
34
+
35
+ # targets: Array<State::Target>. elapsed: ->(target) { seconds }.
36
+ # keys: Array<[key, description]>, or nil to omit the keymap row.
37
+ def render(targets, cols:, keys: nil, frame: 0, elapsed: nil, rule: true)
38
+ cols = [cols.to_i, 1].max
39
+ lines = []
40
+ lines << UI.gray("─" * cols) if rule
41
+ lines << status_row(targets, cols, frame, elapsed)
42
+ lines << keys_row(keys, cols) if keys
43
+ lines
44
+ end
45
+
46
+ # --- status row ----------------------------------------------------------
47
+
48
+ # Three levels of degradation, tried in order until one fits: everything;
49
+ # details only on what's actually working; then drop idle targets. A hard
50
+ # truncation is the last resort.
51
+ def status_row(targets, cols, frame, elapsed)
52
+ segments = targets.map { |t| segment(t, frame, elapsed) }
53
+
54
+ candidates = [
55
+ segments,
56
+ segments.map { |s| s[:busy] ? s : s.merge(detail: nil) },
57
+ segments.select { |s| s[:state] != :idle }
58
+ ]
59
+ chosen = candidates.find { |set| plain_width(set) <= cols } || candidates.last
60
+ fit(chosen.map { |s| [plain(s), painted(s)] }, cols)
61
+ end
62
+
63
+ def segment(target, frame, elapsed)
64
+ glyph, color = GLYPHS.fetch(target.state, GLYPHS[:idle])
65
+ { glyph: glyph || UI::FRAMES[frame % UI::FRAMES.size],
66
+ color: color,
67
+ label: target.label,
68
+ detail: detail_for(target, elapsed),
69
+ state: target.state,
70
+ busy: target.busy? }
71
+ end
72
+
73
+ # Busy targets carry a running clock — the whole reason the dock exists is
74
+ # that a cold Gradle build looks identical to a hung one without it.
75
+ def detail_for(target, elapsed)
76
+ if target.busy?
77
+ seconds = elapsed&.call(target)
78
+ [target.state == :building ? nil : target.state.to_s,
79
+ (UI.elapsed(seconds) if seconds)].compact.join(" ")
80
+ else
81
+ target.detail
82
+ end
83
+ end
84
+
85
+ def plain(seg)
86
+ [seg[:glyph], seg[:label], presence(seg[:detail])].compact.join(" ")
87
+ end
88
+
89
+ def painted(seg)
90
+ tint = UI.method(seg[:color])
91
+ parts = [tint.call(seg[:glyph]), seg[:state] == :idle ? UI.gray(seg[:label]) : seg[:label]]
92
+ parts << UI.dim(seg[:detail]) if presence(seg[:detail])
93
+ parts.join(" ")
94
+ end
95
+
96
+ def plain_width(segments) = segments.sum { |s| plain(s).length } + (GAP.length * [segments.size - 1, 0].max)
97
+
98
+ # --- keys row ------------------------------------------------------------
99
+
100
+ # Named keys with dot separators, then named keys packed tighter, then
101
+ # bare letters. Measured on the plain text and painted only once a tier
102
+ # fits, so the width math can't be thrown off by SGR codes.
103
+ def keys_row(keys, cols)
104
+ tiers = [
105
+ [:named, KEY_GAP],
106
+ [:named, " "],
107
+ [:bare, " "]
108
+ ]
109
+ tiers.each do |style, gap|
110
+ plain = "⌨ #{keys.map { |key, desc| plain_key(key, desc, style) }.join(gap)}"
111
+ next unless plain.length <= cols
112
+
113
+ painted = keys.map { |key, desc| paint_key(key, desc, style) }.join(gap == KEY_GAP ? UI.gray(gap) : gap)
114
+ return "#{UI.cyan("⌨")} #{painted}"
115
+ end
116
+ ""
117
+ end
118
+
119
+ def plain_key(key, desc, style) = style == :bare ? key : "#{key} #{desc}"
120
+
121
+ def paint_key(key, desc, style)
122
+ style == :bare ? UI.bold(key) : "#{UI.bold(key)} #{UI.dim(desc)}"
123
+ end
124
+
125
+ # --- fitting -------------------------------------------------------------
126
+
127
+ # pairs: [[plain, painted], …]. Emit painted segments while they fit,
128
+ # measuring only the plain text; cut the first one that doesn't.
129
+ def fit(pairs, cols)
130
+ out = +""
131
+ width = 0
132
+ pairs.each_with_index do |(text, colored), index|
133
+ gap = index.zero? ? "" : GAP
134
+ if width + gap.length + text.length <= cols
135
+ out << gap << colored
136
+ width += gap.length + text.length
137
+ next
138
+ end
139
+
140
+ room = cols - width - gap.length - ELLIPSIS.length
141
+ out << gap << text[0, room] << ELLIPSIS if room.positive?
142
+ break
143
+ end
144
+ out
145
+ end
146
+
147
+ def presence(str) = str.nil? || str.empty? ? nil : str
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Everywhere
4
+ class Dock
5
+ # Emits the escape sequences that keep the footer pinned to the bottom.
6
+ #
7
+ # THE MECHANIC, and why it isn't the obvious one:
8
+ #
9
+ # The tempting approach is DECSTBM — set a scroll region (ESC[1;Nr) that
10
+ # excludes the last few rows and let the terminal itself keep output off
11
+ # them. It works, and it even confines child processes writing straight to
12
+ # the fd. It was rejected for two reasons:
13
+ #
14
+ # 1. It destroys scrollback. tmux only copies scrolled-out rows into its
15
+ # history when the scroll region is the full screen
16
+ # (grid_view_scroll_region_up checks rupper == 0 && rlower == sy - 1);
17
+ # screen and, as far as we can tell, iTerm2 behave the same way. Losing
18
+ # the ability to scroll up and read a Rails backtrace is a far worse
19
+ # regression than the one the dock is fixing.
20
+ # 2. Its failure mode is terrible. If the CLI is SIGKILLed before it can
21
+ # emit ESC[r, the user's terminal keeps a dead band at the bottom until
22
+ # they run `reset`.
23
+ #
24
+ # So instead we own every byte (the dev server and shells are relayed
25
+ # through us, not inherited) and repaint: erase the footer rows, write the
26
+ # new content — which scrolls normally, preserving scrollback everywhere —
27
+ # then draw the footer again. This is what indicatif, Ink, docker compose
28
+ # and cargo all do. Worst-case failure is one stale line on screen.
29
+ #
30
+ # Everything is emitted as a SINGLE write per frame. Byte interleaving
31
+ # between processes happens between write(2) calls, not inside one.
32
+ class Screen
33
+ ERASE_ROW = "\r\e[2K" # clear this row, cursor back to column 1
34
+ UP_ROW = "\e[1A\e[2K" # up one row and clear it
35
+ HIDE = "\e[?25l"
36
+ SHOW = "\e[?25h"
37
+
38
+ DEFAULT_SIZE = [24, 80].freeze
39
+
40
+ def initialize(io:, size: nil)
41
+ @io = io
42
+ @size = size
43
+ @drawn = 0
44
+ @open = false
45
+ end
46
+
47
+ def open
48
+ return if @open
49
+
50
+ @open = true
51
+ @sync = @io.sync
52
+ @io.sync = true
53
+ @io.write(HIDE)
54
+ end
55
+
56
+ # content: text to land above the footer (must end in a newline, or be
57
+ # empty). footer: the rows to pin.
58
+ def frame(content, footer)
59
+ return unless @open
60
+
61
+ out = +""
62
+ out << erase
63
+ out << content
64
+ out << footer.join("\n")
65
+ @drawn = footer.length
66
+ @io.write(out)
67
+ end
68
+
69
+ # Wipe the footer without redrawing it — for anything that has to write
70
+ # outside our frame discipline (stderr, an interactive child).
71
+ def clear
72
+ return unless @open && @drawn.positive?
73
+
74
+ @io.write(erase)
75
+ @drawn = 0
76
+ end
77
+
78
+ def close
79
+ return unless @open
80
+
81
+ @io.write("#{erase}#{SHOW}")
82
+ @drawn = 0
83
+ @open = false
84
+ @io.sync = @sync
85
+ end
86
+
87
+ # Rows and columns, re-read on every resize. IO#winsize is a plain
88
+ # TIOCGWINSZ ioctl — it does not touch termios, so it is safe alongside
89
+ # the `stty -icanon -echo` the key loop sets.
90
+ def size
91
+ return @size if @size
92
+
93
+ require "io/console"
94
+ @io.winsize
95
+ rescue StandardError
96
+ env = [ENV["LINES"].to_i, ENV["COLUMNS"].to_i]
97
+ env.all?(&:positive?) ? env : DEFAULT_SIZE
98
+ end
99
+
100
+ def rows = size[0]
101
+ def cols = size[1]
102
+
103
+ private
104
+
105
+ # Cursor sits at the end of the last footer row; walk back up to column 1
106
+ # of the first, clearing as we go.
107
+ def erase
108
+ return "" if @drawn.zero?
109
+
110
+ ERASE_ROW + (UP_ROW * (@drawn - 1))
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Everywhere
4
+ class Dock
5
+ # What the dock knows about each thing `every dev` is running. Pure data
6
+ # with an injected clock, so elapsed times are deterministic under test.
7
+ class State
8
+ # Ordered so the dock reads left-to-right the way the session starts up.
9
+ ORDER = %i[web desktop ios android].freeze
10
+
11
+ LABELS = {
12
+ web: "web",
13
+ desktop: "desktop",
14
+ ios: "iOS",
15
+ android: "Android"
16
+ }.freeze
17
+
18
+ # States that are "in flight" — they get a spinner and a running clock.
19
+ BUSY = %i[booting building installing].freeze
20
+
21
+ Target = Struct.new(:key, :label, :state, :detail, :since, keyword_init: true) do
22
+ def busy? = BUSY.include?(state)
23
+ end
24
+
25
+ def initialize(keys, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
26
+ @clock = clock
27
+ @lock = Mutex.new
28
+ ordered = ORDER & keys
29
+ @targets = ordered.to_h do |key|
30
+ [key, Target.new(key: key, label: LABELS.fetch(key, key.to_s), state: :idle, since: clock.call)]
31
+ end
32
+ end
33
+
34
+ def set(key, state, detail: nil)
35
+ @lock.synchronize do
36
+ target = @targets[key] or return nil
37
+ # Keep `since` when nothing actually changed, so a repeated
38
+ # set(:ios, :building) from a retry doesn't reset the elapsed clock.
39
+ target.since = @clock.call unless target.state == state
40
+ target.state = state
41
+ target.detail = detail
42
+ target
43
+ end
44
+ end
45
+
46
+ def detail(key, text)
47
+ @lock.synchronize { @targets[key]&.detail = text }
48
+ end
49
+
50
+ def [](key) = @lock.synchronize { @targets[key] }
51
+
52
+ def targets = @lock.synchronize { @targets.values.map(&:dup) }
53
+
54
+ def busy? = @lock.synchronize { @targets.each_value.any?(&:busy?) }
55
+
56
+ def elapsed(target) = @clock.call - target.since
57
+ end
58
+ end
59
+ end