muxr 0.1.11 → 0.2.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/CHANGELOG.md +225 -1
- data/README.md +763 -387
- data/bin/muxr +68 -33
- data/bin/muxr-mcp +172 -16
- data/lib/muxr/application.rb +516 -26
- data/lib/muxr/client.rb +26 -4
- data/lib/muxr/command_dispatcher.rb +69 -0
- data/lib/muxr/config.rb +156 -0
- data/lib/muxr/control_server.rb +301 -3
- data/lib/muxr/history_row.rb +264 -0
- data/lib/muxr/image_store.rb +61 -0
- data/lib/muxr/input_handler.rb +112 -14
- data/lib/muxr/layout_manager.rb +83 -57
- data/lib/muxr/mouse_report.rb +24 -0
- data/lib/muxr/pane.rb +122 -2
- data/lib/muxr/pane_picker.rb +49 -0
- data/lib/muxr/pane_transfer.rb +172 -0
- data/lib/muxr/protocol.rb +51 -5
- data/lib/muxr/pty_process.rb +34 -7
- data/lib/muxr/remote_pane.rb +263 -0
- data/lib/muxr/renderer.rb +259 -76
- data/lib/muxr/session.rb +19 -2
- data/lib/muxr/session_directory.rb +102 -0
- data/lib/muxr/terminal.rb +759 -82
- data/lib/muxr/version.rb +1 -1
- data/lib/muxr/width_probe.rb +121 -0
- data/lib/muxr/window.rb +50 -3
- data/lib/muxr.rb +8 -0
- data/muxr.gemspec +10 -0
- data/skills/muxr-control/SKILL.md +158 -8
- metadata +18 -1
data/lib/muxr/layout_manager.rb
CHANGED
|
@@ -10,22 +10,41 @@ module Muxr
|
|
|
10
10
|
end
|
|
11
11
|
end
|
|
12
12
|
|
|
13
|
-
LAYOUTS = %i[tall wide columns rows grid spiral centered stack monocle].freeze
|
|
13
|
+
LAYOUTS = %i[tall wide columns rows grid spiral centered stack monocle auto].freeze
|
|
14
|
+
|
|
15
|
+
AUTO_SPIRAL_MIN_COLS = 180
|
|
16
|
+
AUTO_SPIRAL_MIN_ROWS = 30
|
|
17
|
+
|
|
18
|
+
class << self
|
|
19
|
+
attr_writer :auto_spiral_min_cols, :auto_spiral_min_rows
|
|
20
|
+
|
|
21
|
+
def auto_spiral_min_cols
|
|
22
|
+
@auto_spiral_min_cols || AUTO_SPIRAL_MIN_COLS
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def auto_spiral_min_rows
|
|
26
|
+
@auto_spiral_min_rows || AUTO_SPIRAL_MIN_ROWS
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
DEFAULT_RATIO = 0.5
|
|
31
|
+
RATIO_BOUNDS = (0.1..0.9)
|
|
14
32
|
|
|
15
33
|
module_function
|
|
16
34
|
|
|
17
|
-
def compute(layout, count, area, focused_index: 0, master_index: 0)
|
|
35
|
+
def compute(layout, count, area, focused_index: 0, master_index: 0, ratio: DEFAULT_RATIO, nmaster: 1)
|
|
18
36
|
return [] if count <= 0
|
|
19
37
|
master_index = master_index.clamp(0, count - 1)
|
|
20
38
|
focused_index = focused_index.clamp(0, count - 1)
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
when :
|
|
39
|
+
ratio = ratio.to_f.clamp(RATIO_BOUNDS)
|
|
40
|
+
case resolve(layout, area)
|
|
41
|
+
when :tall then tall(count, area, master_index, ratio, nmaster)
|
|
42
|
+
when :wide then wide(count, area, master_index, ratio, nmaster)
|
|
24
43
|
when :columns then columns(count, area)
|
|
25
44
|
when :rows then rows(count, area)
|
|
26
45
|
when :grid then grid(count, area)
|
|
27
46
|
when :spiral then spiral(count, area)
|
|
28
|
-
when :centered then centered(count, area, master_index)
|
|
47
|
+
when :centered then centered(count, area, master_index, ratio, nmaster)
|
|
29
48
|
when :stack then stack(count, area, focused_index)
|
|
30
49
|
when :monocle then monocle(count, area, focused_index)
|
|
31
50
|
else
|
|
@@ -33,54 +52,52 @@ module Muxr
|
|
|
33
52
|
end
|
|
34
53
|
end
|
|
35
54
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
return [Rect.new(area.x, area.y, area.w, area.h)] if count == 1
|
|
55
|
+
def resolve(layout, area)
|
|
56
|
+
return layout unless layout == :auto
|
|
57
|
+
spiral_fits?(area) ? :spiral : :stack
|
|
58
|
+
end
|
|
41
59
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
slave_count = others.length
|
|
46
|
-
base_h = area.h / slave_count
|
|
47
|
-
remainder = area.h - base_h * slave_count
|
|
60
|
+
def spiral_fits?(area)
|
|
61
|
+
area.w >= LayoutManager.auto_spiral_min_cols && area.h >= LayoutManager.auto_spiral_min_rows
|
|
62
|
+
end
|
|
48
63
|
|
|
49
|
-
|
|
50
|
-
|
|
64
|
+
def split_masters(count, master_index, nmaster)
|
|
65
|
+
ordered = [master_index] + ((0...count).to_a - [master_index])
|
|
66
|
+
n = nmaster.to_i.clamp(1, count)
|
|
67
|
+
[ordered.first(n), ordered.drop(n)]
|
|
68
|
+
end
|
|
51
69
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
h = base_h + (i < remainder ? 1 : 0)
|
|
55
|
-
rects[idx] = Rect.new(area.x + master_w, y, stack_w, h)
|
|
56
|
-
y += h
|
|
57
|
-
end
|
|
58
|
-
rects
|
|
70
|
+
def master_extent(total, ratio)
|
|
71
|
+
(total * ratio).floor.clamp(1, [total - 1, 1].max)
|
|
59
72
|
end
|
|
60
73
|
|
|
61
|
-
|
|
62
|
-
# half; remaining panes sit side-by-side in the bottom half, dividing the
|
|
63
|
-
# remaining width evenly.
|
|
64
|
-
def wide(count, area, master_index = 0)
|
|
74
|
+
def tall(count, area, master_index = 0, ratio = DEFAULT_RATIO, nmaster = 1)
|
|
65
75
|
master_index = master_index.clamp(0, count - 1)
|
|
66
|
-
|
|
76
|
+
masters, others = split_masters(count, master_index, nmaster)
|
|
77
|
+
rects = Array.new(count)
|
|
78
|
+
if others.empty?
|
|
79
|
+
stack_column(rects, masters, area.x, area.y, area.w, area.h)
|
|
80
|
+
return rects
|
|
81
|
+
end
|
|
67
82
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
others
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
remainder = area.w - base_w * slave_count
|
|
83
|
+
master_w = master_extent(area.w, ratio)
|
|
84
|
+
stack_column(rects, masters, area.x, area.y, master_w, area.h)
|
|
85
|
+
stack_column(rects, others, area.x + master_w, area.y, [area.w - master_w, 1].max, area.h)
|
|
86
|
+
rects
|
|
87
|
+
end
|
|
74
88
|
|
|
89
|
+
def wide(count, area, master_index = 0, ratio = DEFAULT_RATIO, nmaster = 1)
|
|
90
|
+
master_index = master_index.clamp(0, count - 1)
|
|
91
|
+
masters, others = split_masters(count, master_index, nmaster)
|
|
75
92
|
rects = Array.new(count)
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
others.each_with_index do |idx, i|
|
|
80
|
-
w = base_w + (i < remainder ? 1 : 0)
|
|
81
|
-
rects[idx] = Rect.new(x, area.y + master_h, w, stack_h)
|
|
82
|
-
x += w
|
|
93
|
+
if others.empty?
|
|
94
|
+
spread_row(rects, masters, area.x, area.y, area.w, area.h)
|
|
95
|
+
return rects
|
|
83
96
|
end
|
|
97
|
+
|
|
98
|
+
master_h = master_extent(area.h, ratio)
|
|
99
|
+
spread_row(rects, masters, area.x, area.y, area.w, master_h)
|
|
100
|
+
spread_row(rects, others, area.x, area.y + master_h, area.w, [area.h - master_h, 1].max)
|
|
84
101
|
rects
|
|
85
102
|
end
|
|
86
103
|
|
|
@@ -136,30 +153,27 @@ module Muxr
|
|
|
136
153
|
rects
|
|
137
154
|
end
|
|
138
155
|
|
|
139
|
-
|
|
140
|
-
# remaining panes are dealt alternately to a left and a right column and
|
|
141
|
-
# stacked within each. With a single slave there is no symmetry to keep, so
|
|
142
|
-
# it falls back to a simple master/slave vertical split (like `tall`).
|
|
143
|
-
def centered(count, area, master_index = 0)
|
|
156
|
+
def centered(count, area, master_index = 0, ratio = DEFAULT_RATIO, nmaster = 1)
|
|
144
157
|
master_index = master_index.clamp(0, count - 1)
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
others
|
|
148
|
-
|
|
158
|
+
masters, others = split_masters(count, master_index, nmaster)
|
|
159
|
+
rects = Array.new(count)
|
|
160
|
+
if others.empty?
|
|
161
|
+
stack_column(rects, masters, area.x, area.y, area.w, area.h)
|
|
162
|
+
return rects
|
|
163
|
+
end
|
|
149
164
|
|
|
165
|
+
master_w = master_extent(area.w, ratio)
|
|
150
166
|
if others.length == 1
|
|
151
|
-
|
|
152
|
-
rects[master_index] = Rect.new(area.x, area.y, master_w, area.h)
|
|
167
|
+
stack_column(rects, masters, area.x, area.y, master_w, area.h)
|
|
153
168
|
rects[others[0]] = Rect.new(area.x + master_w, area.y, [area.w - master_w, 1].max, area.h)
|
|
154
169
|
return rects
|
|
155
170
|
end
|
|
156
171
|
|
|
157
|
-
master_w = [area.w / 2, 1].max
|
|
158
172
|
side_w = area.w - master_w
|
|
159
173
|
left_w = [side_w / 2, 1].max
|
|
160
174
|
right_w = [side_w - left_w, 1].max
|
|
161
175
|
|
|
162
|
-
rects
|
|
176
|
+
stack_column(rects, masters, area.x + left_w, area.y, master_w, area.h)
|
|
163
177
|
left = others.select.with_index { |_, i| i.even? }
|
|
164
178
|
right = others.select.with_index { |_, i| i.odd? }
|
|
165
179
|
stack_column(rects, left, area.x, area.y, left_w, area.h)
|
|
@@ -205,6 +219,18 @@ module Muxr
|
|
|
205
219
|
end
|
|
206
220
|
end
|
|
207
221
|
|
|
222
|
+
def spread_row(rects, indices, x, y, total_w, h)
|
|
223
|
+
return if indices.empty?
|
|
224
|
+
base_w = total_w / indices.length
|
|
225
|
+
rem = total_w - base_w * indices.length
|
|
226
|
+
cx = x
|
|
227
|
+
indices.each_with_index do |idx, i|
|
|
228
|
+
w = base_w + (i < rem ? 1 : 0)
|
|
229
|
+
rects[idx] = Rect.new(cx, y, w, h)
|
|
230
|
+
cx += w
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
208
234
|
# Roughly square grid. Each row stretches its panes to fill the full width
|
|
209
235
|
# so an underfull bottom row doesn't leave gaps.
|
|
210
236
|
def grid(count, area)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
module Muxr
|
|
2
|
+
module MouseReport
|
|
3
|
+
WHEEL_UP = 64
|
|
4
|
+
WHEEL_DOWN = 65
|
|
5
|
+
X10_COORD_MAX = 223
|
|
6
|
+
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def wheel(direction, row:, col:, encoding: :sgr)
|
|
10
|
+
button = direction == :up ? WHEEL_UP : WHEEL_DOWN
|
|
11
|
+
encoding == :sgr ? sgr(button, row, col) : x10(button, row, col)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def sgr(button, row, col)
|
|
15
|
+
"\e[<#{button};#{[col, 1].max};#{[row, 1].max}M".b
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def x10(button, row, col)
|
|
19
|
+
c = col.clamp(1, X10_COORD_MAX)
|
|
20
|
+
r = row.clamp(1, X10_COORD_MAX)
|
|
21
|
+
"\e[M#{(32 + button).chr}#{(32 + c).chr}#{(32 + r).chr}".b
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
data/lib/muxr/pane.rb
CHANGED
|
@@ -14,6 +14,12 @@ module Muxr
|
|
|
14
14
|
class Pane
|
|
15
15
|
attr_reader :id, :terminal, :process
|
|
16
16
|
attr_accessor :rect
|
|
17
|
+
# "<session>:<pane id>" when this pane mirrors a pane owned by another muxr
|
|
18
|
+
# server; nil for a locally-owned PTY.
|
|
19
|
+
attr_accessor :origin
|
|
20
|
+
# Smallest viewport any remote mirror is showing this pane through, as
|
|
21
|
+
# [rows, cols], or nil when nobody is mirroring.
|
|
22
|
+
attr_accessor :mirror_size
|
|
17
23
|
# Last value written by Application's foreground poller thread. nil when
|
|
18
24
|
# the shell itself is foreground (the common empty-prompt case) or when
|
|
19
25
|
# the lookup hasn't run / couldn't read. Renderer surfaces this in the
|
|
@@ -36,6 +42,17 @@ module Muxr
|
|
|
36
42
|
@initial_cwd = cwd || @process.cwd
|
|
37
43
|
@private_flag = false
|
|
38
44
|
@foreground_command = nil
|
|
45
|
+
@origin = nil
|
|
46
|
+
@mirror_size = nil
|
|
47
|
+
@activity = false
|
|
48
|
+
@bell = false
|
|
49
|
+
@silent = false
|
|
50
|
+
@silence_after = nil
|
|
51
|
+
hush!
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def mirror?
|
|
55
|
+
@process.respond_to?(:mirror?) && @process.mirror?
|
|
39
56
|
end
|
|
40
57
|
|
|
41
58
|
def pid
|
|
@@ -89,6 +106,77 @@ module Muxr
|
|
|
89
106
|
# etc.) instead of once per ~8 KiB chunk — the latter shows intermediate
|
|
90
107
|
# frames and is the main source of in-pane flicker. Bounded by a byte cap
|
|
91
108
|
# so a runaway producer can't starve other panes on a single tick.
|
|
109
|
+
ATTENTION_GRACE = 1.5
|
|
110
|
+
NAME_MAX = 24
|
|
111
|
+
|
|
112
|
+
attr_reader :name
|
|
113
|
+
|
|
114
|
+
def name=(value)
|
|
115
|
+
text = value.to_s.gsub(/[[:cntrl:]]/, "").strip[0, NAME_MAX]
|
|
116
|
+
@name = text.empty? ? nil : text
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def label
|
|
120
|
+
@name || @id.to_s
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def self.now
|
|
124
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def activity?
|
|
128
|
+
@activity
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def bell?
|
|
132
|
+
@bell
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def silent?
|
|
136
|
+
@silent
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
attr_reader :silence_after
|
|
140
|
+
|
|
141
|
+
def note_output(now = Pane.now, attended: false)
|
|
142
|
+
return if now < @quiet_until
|
|
143
|
+
@last_output_at = now
|
|
144
|
+
@silent = false
|
|
145
|
+
@silence_reported = false
|
|
146
|
+
@activity = true unless attended
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def watch_silence(seconds, now = Pane.now)
|
|
150
|
+
@silence_after = seconds
|
|
151
|
+
@last_output_at = now
|
|
152
|
+
@silent = false
|
|
153
|
+
@silence_reported = false
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def silence_due?(now = Pane.now)
|
|
157
|
+
return false unless @silence_after
|
|
158
|
+
!@silence_reported && now - @last_output_at >= @silence_after
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def note_silence!
|
|
162
|
+
@silent = true
|
|
163
|
+
@silence_reported = true
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def note_bell
|
|
167
|
+
@bell = true
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def clear_attention!
|
|
171
|
+
@activity = false
|
|
172
|
+
@bell = false
|
|
173
|
+
@silent = false
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def hush!(now = Pane.now)
|
|
177
|
+
@quiet_until = now + ATTENTION_GRACE
|
|
178
|
+
end
|
|
179
|
+
|
|
92
180
|
READ_BUDGET = 1 << 20 # 1 MiB
|
|
93
181
|
def read_from_pty
|
|
94
182
|
total = 0
|
|
@@ -96,13 +184,16 @@ module Muxr
|
|
|
96
184
|
chunk = @process.read_nonblock
|
|
97
185
|
break unless chunk
|
|
98
186
|
@terminal.feed(chunk)
|
|
187
|
+
yield chunk if block_given?
|
|
99
188
|
total += chunk.bytesize
|
|
100
189
|
end
|
|
101
190
|
# The emulator may owe the inner program a reply (DSR / CPR — see
|
|
102
191
|
# Terminal#take_pending_replies!). Ship it back through the PTY's
|
|
103
192
|
# input side as if it had been typed. Failure here is non-fatal: the
|
|
104
|
-
# process can have exited between read and write.
|
|
105
|
-
|
|
193
|
+
# process can have exited between read and write. A mirror stays quiet:
|
|
194
|
+
# the pane's owner already answered, and a second answer would reach the
|
|
195
|
+
# program as spurious keystrokes.
|
|
196
|
+
if (reply = @terminal.take_pending_replies!) && !mirror?
|
|
106
197
|
begin
|
|
107
198
|
@process.write(reply)
|
|
108
199
|
rescue Errno::EIO, Errno::EPIPE
|
|
@@ -111,8 +202,33 @@ module Muxr
|
|
|
111
202
|
total.positive? ? total : nil
|
|
112
203
|
end
|
|
113
204
|
|
|
205
|
+
# A mirror's grid is sized by the pane's owner: the relayed stream carries
|
|
206
|
+
# absolute cursor addresses for *that* geometry. We only forward the
|
|
207
|
+
# viewport we can offer and wait for the size the owner settles on.
|
|
114
208
|
def resize(rows, cols)
|
|
209
|
+
if mirror?
|
|
210
|
+
hush! unless rows == @terminal.rows && cols == @terminal.cols
|
|
211
|
+
return @process.resize(rows, cols)
|
|
212
|
+
end
|
|
213
|
+
rows, cols = fit_to_mirrors(rows, cols)
|
|
214
|
+
return if rows == @terminal.rows && cols == @terminal.cols
|
|
215
|
+
hush!
|
|
216
|
+
@terminal.resize(rows, cols)
|
|
217
|
+
@process.resize(rows, cols)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def fit_to_mirrors(rows, cols)
|
|
221
|
+
return [rows, cols] unless @mirror_size
|
|
222
|
+
[[rows, @mirror_size[0]].min.clamp(1, rows), [cols, @mirror_size[1]].min.clamp(1, cols)]
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Shrink to fit a mirror that just arrived, without waiting for a render —
|
|
226
|
+
# this session may be detached, in which case no render is coming. Growing
|
|
227
|
+
# back is the layout's call on the next frame it draws.
|
|
228
|
+
def clamp_to_mirrors!
|
|
229
|
+
rows, cols = fit_to_mirrors(@terminal.rows, @terminal.cols)
|
|
115
230
|
return if rows == @terminal.rows && cols == @terminal.cols
|
|
231
|
+
hush!
|
|
116
232
|
@terminal.resize(rows, cols)
|
|
117
233
|
@process.resize(rows, cols)
|
|
118
234
|
end
|
|
@@ -131,6 +247,10 @@ module Muxr
|
|
|
131
247
|
@process.cwd || @initial_cwd
|
|
132
248
|
end
|
|
133
249
|
|
|
250
|
+
def relinquish!
|
|
251
|
+
@process.relinquish! if @process.respond_to?(:relinquish!)
|
|
252
|
+
end
|
|
253
|
+
|
|
134
254
|
def close
|
|
135
255
|
@process.close
|
|
136
256
|
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
module Muxr
|
|
2
|
+
# Selection state for the attach overlay: a flat, navigable list of every
|
|
3
|
+
# pane offered by the other muxr servers on this machine, with session
|
|
4
|
+
# headings interleaved so the list reads as a grouped tree while the cursor
|
|
5
|
+
# only ever lands on something selectable.
|
|
6
|
+
class PanePicker
|
|
7
|
+
Row = Struct.new(:kind, :session, :entry) do
|
|
8
|
+
def selectable?
|
|
9
|
+
kind == :pane
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
attr_reader :rows, :index
|
|
14
|
+
|
|
15
|
+
def initialize(entries)
|
|
16
|
+
@rows = build_rows(entries)
|
|
17
|
+
@index = @rows.index(&:selectable?) || 0
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def empty?
|
|
21
|
+
@rows.none?(&:selectable?)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def selected
|
|
25
|
+
row = @rows[@index]
|
|
26
|
+
row&.selectable? ? row.entry : nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def move(delta)
|
|
30
|
+
return if empty?
|
|
31
|
+
i = @index
|
|
32
|
+
@rows.length.times do
|
|
33
|
+
i = (i + delta) % @rows.length
|
|
34
|
+
next unless @rows[i].selectable?
|
|
35
|
+
@index = i
|
|
36
|
+
return
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def build_rows(entries)
|
|
43
|
+
entries.group_by(&:session).flat_map do |session, panes|
|
|
44
|
+
[Row.new(:session, session, nil)] +
|
|
45
|
+
panes.map { |entry| Row.new(:pane, session, entry) }
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "socket"
|
|
3
|
+
|
|
4
|
+
module Muxr
|
|
5
|
+
# Takes a pane away from another muxr server for good. Where RemotePane
|
|
6
|
+
# borrows a pane by relaying its bytes, this moves the pane itself: the
|
|
7
|
+
# master pty file descriptor crosses the Unix socket via SCM_RIGHTS, so the
|
|
8
|
+
# shell that was running keeps running, with its jobs, its environment and
|
|
9
|
+
# whatever it had on screen, and simply belongs to us afterwards.
|
|
10
|
+
#
|
|
11
|
+
# Two things shape the wire order. The fd has to arrive *before* the bulky
|
|
12
|
+
# emulator state, because pulling it out of the socket needs recvmsg and any
|
|
13
|
+
# plain read that swallows the carrier byte first drops the fd on the floor.
|
|
14
|
+
# And a short preamble has to arrive before *that*, because a refusal (the
|
|
15
|
+
# pane is private, or the last one in its session) has no fd to send and we
|
|
16
|
+
# would otherwise block forever waiting for one. So: preamble, fd, state.
|
|
17
|
+
#
|
|
18
|
+
# The move is two-phase. Nothing is torn down on the far side until we have
|
|
19
|
+
# a working pane here and say so, and a failure anywhere leaves the pane
|
|
20
|
+
# exactly where it was rather than dropping a live shell between servers.
|
|
21
|
+
module PaneTransfer
|
|
22
|
+
class Error < StandardError; end
|
|
23
|
+
|
|
24
|
+
TIMEOUT = 5.0
|
|
25
|
+
|
|
26
|
+
Result = Struct.new(:pane, :session, keyword_init: true)
|
|
27
|
+
|
|
28
|
+
def self.claim(socket_path:, pane_id:)
|
|
29
|
+
socket = connect(socket_path)
|
|
30
|
+
begin
|
|
31
|
+
request(socket, 1, "pane.move", "pane" => pane_id)
|
|
32
|
+
await_preamble(socket)
|
|
33
|
+
io = receive_fd(socket)
|
|
34
|
+
begin
|
|
35
|
+
state = await_state(socket)
|
|
36
|
+
pane = build_pane(io, state)
|
|
37
|
+
rescue StandardError => e
|
|
38
|
+
io.close rescue nil
|
|
39
|
+
notify(socket, "pane.move_abort")
|
|
40
|
+
raise e.is_a?(Error) ? e : Error.new(e.message)
|
|
41
|
+
end
|
|
42
|
+
request(socket, 2, "pane.move_commit")
|
|
43
|
+
await_result(socket, 2)
|
|
44
|
+
Result.new(pane: pane, session: state["session"].to_s)
|
|
45
|
+
ensure
|
|
46
|
+
socket.close rescue nil
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def self.build_pane(io, state)
|
|
51
|
+
rows = state["rows"].to_i
|
|
52
|
+
cols = state["cols"].to_i
|
|
53
|
+
raise Error, "owner sent a nonsense pane size (#{rows}x#{cols})" unless rows.positive? && cols.positive?
|
|
54
|
+
process = PTYProcess.new(
|
|
55
|
+
rows: rows, cols: cols,
|
|
56
|
+
adopt_io: io, adopt_pid: state["pid"]
|
|
57
|
+
)
|
|
58
|
+
pane = Pane.new(id: state["pane"].to_s, rows: rows, cols: cols, cwd: state["cwd"], process: process)
|
|
59
|
+
pane.terminal.restore_transfer!(state)
|
|
60
|
+
pane.name = state["name"] if state["name"]
|
|
61
|
+
pane
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def self.connect(socket_path)
|
|
65
|
+
UNIXSocket.new(socket_path)
|
|
66
|
+
rescue SystemCallError => e
|
|
67
|
+
raise Error, "cannot reach session socket: #{e.message}"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def self.request(socket, id, method, params = {})
|
|
71
|
+
socket.write(JSON.generate("id" => id, "method" => method, "params" => params) + "\n")
|
|
72
|
+
rescue SystemCallError, IOError => e
|
|
73
|
+
raise Error, e.message
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def self.notify(socket, method, params = {})
|
|
77
|
+
socket.write(JSON.generate("method" => method, "params" => params) + "\n")
|
|
78
|
+
rescue SystemCallError, IOError
|
|
79
|
+
# Owner will time the half-finished move out on its own.
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Read the preamble a byte at a time. Reading ahead here would be a bug,
|
|
83
|
+
# not an optimization: the very next thing on the socket is the fd, and a
|
|
84
|
+
# buffered read past the newline would consume its carrier byte.
|
|
85
|
+
def self.await_preamble(socket)
|
|
86
|
+
line = +""
|
|
87
|
+
deadline = now + TIMEOUT
|
|
88
|
+
until line.end_with?("\n")
|
|
89
|
+
raise Error, "timed out waiting for the owning session" unless wait_readable(socket, deadline)
|
|
90
|
+
byte = begin
|
|
91
|
+
socket.read_nonblock(1)
|
|
92
|
+
rescue IO::WaitReadable
|
|
93
|
+
next
|
|
94
|
+
rescue EOFError, SystemCallError, IOError
|
|
95
|
+
raise Error, "owning session closed the connection"
|
|
96
|
+
end
|
|
97
|
+
line << byte
|
|
98
|
+
raise Error, "owning session sent an oversized reply" if line.bytesize > 8192
|
|
99
|
+
end
|
|
100
|
+
check(parse(line))
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def self.receive_fd(socket)
|
|
104
|
+
raise Error, "timed out waiting for the pane's terminal" unless wait_readable(socket, now + TIMEOUT)
|
|
105
|
+
socket.recv_io(IO, "r+")
|
|
106
|
+
rescue SystemCallError, IOError => e
|
|
107
|
+
raise Error, "could not take over the pane's terminal: #{e.message}"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Safe to buffer freely now — the fd is out of the socket and the state
|
|
111
|
+
# line is the last thing the owner sends before we commit.
|
|
112
|
+
def self.await_state(socket)
|
|
113
|
+
buffer = +""
|
|
114
|
+
deadline = now + TIMEOUT
|
|
115
|
+
until (nl = buffer.index("\n"))
|
|
116
|
+
raise Error, "timed out waiting for the pane's contents" unless wait_readable(socket, deadline)
|
|
117
|
+
begin
|
|
118
|
+
buffer << socket.read_nonblock(READ_CHUNK)
|
|
119
|
+
rescue IO::WaitReadable
|
|
120
|
+
next
|
|
121
|
+
rescue EOFError, SystemCallError, IOError
|
|
122
|
+
raise Error, "owning session closed the connection"
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
check(parse(buffer.byteslice(0, nl + 1)))
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def self.await_result(socket, id)
|
|
129
|
+
buffer = +""
|
|
130
|
+
deadline = now + TIMEOUT
|
|
131
|
+
loop do
|
|
132
|
+
while (nl = buffer.index("\n"))
|
|
133
|
+
line = buffer.slice!(0..nl)
|
|
134
|
+
msg = parse(line)
|
|
135
|
+
next unless msg["id"] == id
|
|
136
|
+
return check(msg)
|
|
137
|
+
end
|
|
138
|
+
raise Error, "timed out completing the move" unless wait_readable(socket, deadline)
|
|
139
|
+
begin
|
|
140
|
+
buffer << socket.read_nonblock(READ_CHUNK)
|
|
141
|
+
rescue IO::WaitReadable
|
|
142
|
+
next
|
|
143
|
+
rescue EOFError, SystemCallError, IOError
|
|
144
|
+
raise Error, "owning session closed the connection"
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
READ_CHUNK = 64 * 1024
|
|
150
|
+
|
|
151
|
+
def self.check(msg)
|
|
152
|
+
raise Error, msg["error"]["message"].to_s if msg["error"]
|
|
153
|
+
msg["result"] || {}
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def self.parse(line)
|
|
157
|
+
JSON.parse(line)
|
|
158
|
+
rescue JSON::ParserError
|
|
159
|
+
raise Error, "owning session sent something that isn't JSON"
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def self.wait_readable(socket, deadline)
|
|
163
|
+
remaining = deadline - now
|
|
164
|
+
return false if remaining <= 0
|
|
165
|
+
!!IO.select([socket], nil, nil, remaining)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def self.now
|
|
169
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
data/lib/muxr/protocol.rb
CHANGED
|
@@ -55,21 +55,67 @@ module Muxr
|
|
|
55
55
|
buf
|
|
56
56
|
end
|
|
57
57
|
|
|
58
|
-
# Encodes a "ROWS COLS" string for HELLO / RESIZE payloads.
|
|
59
|
-
|
|
60
|
-
|
|
58
|
+
# Encodes a "ROWS COLS" string for HELLO / RESIZE payloads. Optional
|
|
59
|
+
# capabilities (from the width probe) ride along as trailing "key=val"
|
|
60
|
+
# tokens, e.g. "48 200 ambiguous=2". Older servers that split on the first
|
|
61
|
+
# two tokens still read the size correctly, and a server that doesn't know a
|
|
62
|
+
# given cap simply ignores it.
|
|
63
|
+
def self.encode_size(rows, cols, caps = nil)
|
|
64
|
+
base = "#{rows.to_i} #{cols.to_i}"
|
|
65
|
+
return base if caps.nil? || caps.empty?
|
|
66
|
+
base + " " + encode_caps(caps)
|
|
61
67
|
end
|
|
62
68
|
|
|
63
|
-
#
|
|
69
|
+
# Serializes a caps hash to whitespace-free "key=value" tokens. Integer
|
|
70
|
+
# values encode as "ambiguous=2"; a codepoint=>width map (the width probe's
|
|
71
|
+
# per-glyph overrides) encodes as "glyphs=23fa:2,273b:1" with hex codepoints.
|
|
72
|
+
def self.encode_caps(caps)
|
|
73
|
+
caps.filter_map do |k, v|
|
|
74
|
+
if v.is_a?(Hash)
|
|
75
|
+
next if v.empty?
|
|
76
|
+
"#{k}=" + v.map { |cp, w| "#{cp.to_s(16)}:#{w.to_i}" }.join(",")
|
|
77
|
+
else
|
|
78
|
+
"#{k}=#{v.to_i}"
|
|
79
|
+
end
|
|
80
|
+
end.join(" ")
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Returns [rows, cols] from a HELLO/RESIZE payload, or nil if malformed.
|
|
84
|
+
# Tolerates trailing caps tokens by reading only the first two integers.
|
|
64
85
|
def self.decode_size(payload)
|
|
65
86
|
parts = payload.to_s.strip.split(/\s+/)
|
|
66
|
-
return nil unless parts.length
|
|
87
|
+
return nil unless parts.length >= 2
|
|
67
88
|
r = Integer(parts[0]) rescue nil
|
|
68
89
|
c = Integer(parts[1]) rescue nil
|
|
69
90
|
return nil unless r && c
|
|
70
91
|
[r, c]
|
|
71
92
|
end
|
|
72
93
|
|
|
94
|
+
# Pulls the trailing "key=val" capability tokens out of a HELLO payload into
|
|
95
|
+
# a symbol-keyed hash. Plain integer values decode to Integer; a value with
|
|
96
|
+
# "hex:width" pairs decodes to a {codepoint => width} hash (inverse of
|
|
97
|
+
# encode_caps). Unknown / malformed tokens are skipped, never raised on.
|
|
98
|
+
def self.decode_caps(payload)
|
|
99
|
+
caps = {}
|
|
100
|
+
payload.to_s.strip.split(/\s+/).each do |tok|
|
|
101
|
+
next unless tok.include?("=")
|
|
102
|
+
k, v = tok.split("=", 2)
|
|
103
|
+
next unless k =~ /\A[a-z_]+\z/
|
|
104
|
+
if v.include?(":")
|
|
105
|
+
map = {}
|
|
106
|
+
v.split(",").each do |pair|
|
|
107
|
+
cp, w = pair.split(":", 2)
|
|
108
|
+
next unless cp =~ /\A[0-9a-fA-F]+\z/ && w =~ /\A\d+\z/
|
|
109
|
+
map[cp.to_i(16)] = w.to_i
|
|
110
|
+
end
|
|
111
|
+
caps[k.to_sym] = map unless map.empty?
|
|
112
|
+
elsif v =~ /\A-?\d+\z/
|
|
113
|
+
caps[k.to_sym] = v.to_i
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
caps
|
|
117
|
+
end
|
|
118
|
+
|
|
73
119
|
def self.read_exact(io, n)
|
|
74
120
|
buf = +""
|
|
75
121
|
while buf.bytesize < n
|