vanken 0.1.0 → 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 +15 -0
- data/README.md +26 -11
- data/data/coloring_rules.yml +67 -0
- data/docs/performance.md +10 -0
- data/docs/releases.md +2 -4
- data/docs/usage.md +51 -0
- data/lib/vanken/app/capture_controller.rb +66 -1
- data/lib/vanken/app/document.rb +102 -39
- data/lib/vanken/app/document_analysis.rb +75 -0
- data/lib/vanken/app/document_jobs.rb +52 -9
- data/lib/vanken/app/expert_info.rb +26 -0
- data/lib/vanken/app/io_graph.rb +47 -0
- data/lib/vanken/app/navigation.rb +77 -0
- data/lib/vanken/app/search_job.rb +62 -0
- data/lib/vanken/app/stats_job.rb +45 -0
- data/lib/vanken/capture/analyzer.rb +3 -1
- data/lib/vanken/capture/analyzer_process.rb +9 -6
- data/lib/vanken/capture/analyzer_worker.rb +2 -2
- data/lib/vanken/capture/filter_worker.rb +8 -2
- data/lib/vanken/capture/helper_main.rb +10 -3
- data/lib/vanken/capture/helper_options.rb +6 -2
- data/lib/vanken/capture/launcher.rb +1 -0
- data/lib/vanken/config/analysis_settings.rb +58 -0
- data/lib/vanken/config/columns.rb +138 -0
- data/lib/vanken/config/messages.rb +105 -0
- data/lib/vanken/config/preferences.rb +34 -8
- data/lib/vanken/config/profiles.rb +74 -0
- data/lib/vanken/config/sessions.rb +69 -0
- data/lib/vanken/config/yaml_file.rb +36 -0
- data/lib/vanken/core/coloring.rb +86 -0
- data/lib/vanken/core/frame_store.rb +2 -2
- data/lib/vanken/core/stores.rb +12 -0
- data/lib/vanken/gateway/detail_builder.rb +1 -1
- data/lib/vanken/gateway/display_capture_filter.rb +102 -0
- data/lib/vanken/gateway/dissector.rb +31 -6
- data/lib/vanken/gateway/exporter.rb +125 -0
- data/lib/vanken/gateway/file_writer.rb +26 -2
- data/lib/vanken/gateway/interfaces.rb +59 -0
- data/lib/vanken/gateway/resolver.rb +131 -0
- data/lib/vanken/gateway/statistics.rb +83 -0
- data/lib/vanken/gateway/stream.rb +159 -0
- data/lib/vanken/ui/actions.rb +54 -28
- data/lib/vanken/ui/analysis_dialogs.rb +371 -0
- data/lib/vanken/ui/application.rb +183 -9
- data/lib/vanken/ui/coloring_operations.rb +104 -0
- data/lib/vanken/ui/column_operations.rb +115 -0
- data/lib/vanken/ui/dialogs.rb +41 -26
- data/lib/vanken/ui/file_operations.rb +17 -12
- data/lib/vanken/ui/filter_operations.rb +43 -15
- data/lib/vanken/ui/main_view.rb +74 -29
- data/lib/vanken/ui/navigation_operations.rb +86 -0
- data/lib/vanken/ui/packet_source.rb +33 -1
- data/lib/vanken/ui/selection.rb +8 -2
- data/lib/vanken/ui/settings_operations.rb +233 -0
- data/lib/vanken/version.rb +1 -1
- data/sig/generated/vanken/app/capture_controller.rbs +16 -0
- data/sig/generated/vanken/app/document.rbs +23 -1
- data/sig/generated/vanken/app/document_analysis.rbs +21 -0
- data/sig/generated/vanken/app/document_jobs.rbs +6 -0
- data/sig/generated/vanken/app/expert_info.rbs +30 -0
- data/sig/generated/vanken/app/io_graph.rbs +45 -0
- data/sig/generated/vanken/app/navigation.rbs +31 -0
- data/sig/generated/vanken/app/search_job.rbs +21 -0
- data/sig/generated/vanken/app/stats_job.rbs +18 -0
- data/sig/generated/vanken/capture/helper_options.rbs +6 -0
- data/sig/generated/vanken/config/analysis_settings.rbs +31 -0
- data/sig/generated/vanken/config/columns.rbs +37 -0
- data/sig/generated/vanken/config/messages.rbs +14 -0
- data/sig/generated/vanken/config/preferences.rbs +1 -1
- data/sig/generated/vanken/config/profiles.rbs +29 -0
- data/sig/generated/vanken/config/sessions.rbs +17 -0
- data/sig/generated/vanken/config/yaml_file.rbs +11 -0
- data/sig/generated/vanken/core/coloring.rbs +52 -0
- data/sig/generated/vanken/core/stores.rbs +4 -0
- data/sig/generated/vanken/gateway/display_capture_filter.rbs +37 -0
- data/sig/generated/vanken/gateway/dissector.rbs +15 -2
- data/sig/generated/vanken/gateway/exporter.rbs +27 -0
- data/sig/generated/vanken/gateway/file_writer.rbs +3 -0
- data/sig/generated/vanken/gateway/interfaces.rbs +12 -0
- data/sig/generated/vanken/gateway/resolver.rbs +52 -0
- data/sig/generated/vanken/gateway/statistics.rbs +104 -0
- data/sig/generated/vanken/gateway/stream.rbs +75 -0
- data/sig/generated/vanken/ui/analysis_dialogs.rbs +71 -0
- data/sig/generated/vanken/ui/application.rbs +23 -1
- data/sig/generated/vanken/ui/coloring_operations.rbs +29 -0
- data/sig/generated/vanken/ui/column_operations.rbs +29 -0
- data/sig/generated/vanken/ui/dialogs.rbs +5 -3
- data/sig/generated/vanken/ui/file_operations.rbs +2 -0
- data/sig/generated/vanken/ui/filter_operations.rbs +3 -1
- data/sig/generated/vanken/ui/main_view.rbs +14 -2
- data/sig/generated/vanken/ui/navigation_operations.rbs +43 -0
- data/sig/generated/vanken/ui/packet_source.rbs +4 -0
- data/sig/generated/vanken/ui/settings_operations.rbs +41 -0
- metadata +51 -3
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require_relative "../app/stats_job"
|
|
5
|
+
require_relative "../app/expert_info"
|
|
6
|
+
require_relative "../app/io_graph"
|
|
7
|
+
require_relative "../gateway/stream"
|
|
8
|
+
require_relative "../gateway/exporter"
|
|
9
|
+
|
|
10
|
+
module Vanken
|
|
11
|
+
module UI
|
|
12
|
+
class AnalysisRows
|
|
13
|
+
def initialize(rows) = (@rows = rows)
|
|
14
|
+
def count = @rows.size
|
|
15
|
+
def row_id(index) = index
|
|
16
|
+
def cell(index, key)
|
|
17
|
+
value = @rows.fetch(index).to_h[key]
|
|
18
|
+
value.is_a?(Array) ? value.join(" / ") : value
|
|
19
|
+
end
|
|
20
|
+
def sort(key, direction)
|
|
21
|
+
@rows.sort_by! { |row| row.to_h[key] || "" }
|
|
22
|
+
@rows.reverse! if direction == :desc
|
|
23
|
+
end
|
|
24
|
+
def [](index) = @rows.fetch(index)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
module AnalysisDialogs
|
|
28
|
+
ANALYSIS_KINDS = %i[protocol_hierarchy conversations endpoints expert_info io_graph follow_tcp_stream file_properties].freeze
|
|
29
|
+
|
|
30
|
+
def close_analysis
|
|
31
|
+
@analysis_generation = (@analysis_generation || 0) + 1
|
|
32
|
+
@statistics_job&.cancel
|
|
33
|
+
@statistics_job = nil
|
|
34
|
+
# Await actual completion; Task#cancel alone does not join an executing worker.
|
|
35
|
+
@analysis_task&.await
|
|
36
|
+
rescue StandardError
|
|
37
|
+
nil
|
|
38
|
+
ensure
|
|
39
|
+
@analysis_task = @analysis_results = @analysis_refresh = nil
|
|
40
|
+
@follow_stream = @follow_text = nil
|
|
41
|
+
@analysis_running = false
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def refresh_analysis
|
|
45
|
+
return unless @analysis_refresh && ANALYSIS_KINDS.include?(@dialog_kind) && !@analysis_running && !@closing
|
|
46
|
+
@analysis_refresh.call
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def protocol_hierarchy = statistics_dialog(:protocol_hierarchy)
|
|
50
|
+
def conversations = statistics_dialog(:conversations)
|
|
51
|
+
def endpoints = statistics_dialog(:endpoints)
|
|
52
|
+
|
|
53
|
+
def statistics_dialog(kind, displayed_only: false)
|
|
54
|
+
return unless document
|
|
55
|
+
types = %w[eth ip ipv6 tcp udp]
|
|
56
|
+
specs = kind == :protocol_hierarchy ? ["phs"] : types.map { |type| "#{kind == :conversations ? 'conv' : 'endpoints'},#{type}" }
|
|
57
|
+
controls = Zaniah::UI::Checkbox.new(t("表示中のパケットのみ"), value: displayed_only).test_id("vk.stats.displayed")
|
|
58
|
+
.on_change { |value, *_| statistics_dialog(kind, displayed_only: value) }
|
|
59
|
+
analysis_dialog(kind, {protocol_hierarchy: t("プロトコル階層"), conversations: t("会話"), endpoints: t("端点")}.fetch(kind), controls, reopen: -> { statistics_dialog(kind, displayed_only: displayed_only) })
|
|
60
|
+
@statistics_job = App::StatsJob.new(document, specs: specs, displayed_only: displayed_only)
|
|
61
|
+
job = @statistics_job
|
|
62
|
+
@analysis_refresh = lambda do
|
|
63
|
+
analysis_background(->(*) { job.refresh }) do |tables|
|
|
64
|
+
if kind == :protocol_hierarchy
|
|
65
|
+
rows = tables.first.rows
|
|
66
|
+
total = rows.select { |row| row.path.size == 1 }.sum(&:packets)
|
|
67
|
+
nodes = rows.to_h do |row|
|
|
68
|
+
id = row.path.join("/")
|
|
69
|
+
label = t("%{protocol} %{packets} パケット %{bytes} bytes %{percent}%", protocol: row.path.last, packets: row.packets, bytes: row.bytes, percent: total.zero? ? 0 : (row.packets * 100.0 / total).round(1))
|
|
70
|
+
[id, Core::DetailNode.new(id: id, label: label, field: nil, offset: nil, length: 0, source: :frame, severity: nil, filter: nil, children: [])]
|
|
71
|
+
end
|
|
72
|
+
roots = []
|
|
73
|
+
rows.each do |row|
|
|
74
|
+
node = nodes.fetch(row.path.join("/"))
|
|
75
|
+
parent = nodes[row.path.take(row.path.size - 1).join("/")]
|
|
76
|
+
parent ? parent.children << node : roots << node
|
|
77
|
+
end
|
|
78
|
+
analysis_content(Zaniah::UI::TreeView.new(roots, height: 300, label: t("プロトコル階層")).test_id("vk.stats.hierarchy"))
|
|
79
|
+
else
|
|
80
|
+
tabs = tables.map { |table| [table.type.to_s.upcase, statistics_table(table)] }
|
|
81
|
+
analysis_content(Zaniah::UI::Tabs.new(tabs, selected: @statistics_tab || 0)
|
|
82
|
+
.on_change { |index, *_| @statistics_tab = index }.test_id("vk.stats.tabs"))
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
refresh_analysis
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def expert_info(displayed_only: false)
|
|
90
|
+
return unless document
|
|
91
|
+
controls = Zaniah::UI::Checkbox.new(t("表示中のパケットのみ"), value: displayed_only)
|
|
92
|
+
.on_change { |value, *_| expert_info(displayed_only: value) }
|
|
93
|
+
analysis_dialog(:expert_info, t("エキスパート情報"), controls, reopen: -> { expert_info(displayed_only: displayed_only) })
|
|
94
|
+
@analysis_refresh = lambda do
|
|
95
|
+
analysis_background(->(doc, *) { App::ExpertInfo.rows(doc, displayed_only: displayed_only) }) do |rows|
|
|
96
|
+
table = analysis_table(rows, %i[severity code protocol count message]) do |row, event|
|
|
97
|
+
select_packet(row.numbers.first) if event&.click_count.to_i >= 2
|
|
98
|
+
end.test_id("vk.expert.table")
|
|
99
|
+
analysis_content(table)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
refresh_analysis
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def io_graph(interval: 1, expressions: [""], displayed_only: false)
|
|
106
|
+
return unless document
|
|
107
|
+
interval_control = Zaniah::UI::Select.new(App::IOGraph::INTERVALS.map { |value| [t("%{value}秒", value: value), value] }, value: interval, label: t("間隔"))
|
|
108
|
+
.test_id("vk.io.interval")
|
|
109
|
+
filters = Zaniah::UI::TextArea.new(expressions.join("\n"), rows: 2, label: t("系列の表示フィルタ(1行に1系列、空行は全件)")).test_id("vk.io.series")
|
|
110
|
+
displayed = Zaniah::UI::Checkbox.new(t("表示中のパケットのみ"), value: displayed_only)
|
|
111
|
+
redraw = lambda { io_graph(interval: interval, expressions: filters.value.split("\n", -1).uniq, displayed_only: displayed.value) }
|
|
112
|
+
interval_control.on_change { |value, *_| interval = value; redraw.call }
|
|
113
|
+
displayed.on_change { redraw.call }
|
|
114
|
+
controls = Zaniah::Div.new.flex_col.gap(6).child(interval_control).child(filters).child(displayed)
|
|
115
|
+
.child(Zaniah::UI::Button.new(t("系列を適用")).test_id("vk.io.apply").on_click { redraw.call })
|
|
116
|
+
analysis_dialog(:io_graph, t("I/O グラフ"), controls, reopen: redraw)
|
|
117
|
+
@analysis_refresh = lambda do
|
|
118
|
+
analysis_background(->(doc, cancelled) { App::IOGraph.build(doc, interval: interval, series: expressions, displayed_only: displayed_only, cancelled: cancelled) }) do |series|
|
|
119
|
+
if series.first.points.empty?
|
|
120
|
+
analysis_content(Zaniah::UI::Label.new(t("表示するパケットがありません。")))
|
|
121
|
+
next
|
|
122
|
+
end
|
|
123
|
+
values = series.each_with_index.to_h { |item, index| [item.expression.empty? ? t("全件 %{index}", index: index + 1) : item.expression, item.points.map(&:packets)] }
|
|
124
|
+
omitted = series.first&.omitted_intervals || 0
|
|
125
|
+
content = Zaniah::Div.new.flex_col.gap(4)
|
|
126
|
+
content.child(Zaniah::UI::Label.new(t("先頭の%{count}区間を省略しています。間隔を広げると全期間を表示できます。", count: omitted), wrap: :word)) if omitted.positive?
|
|
127
|
+
content.child(Zaniah::UI::LineChart.new(values, width: 660, height: 220, label: t("区間ごとのパケット数")).test_id("vk.io.chart"))
|
|
128
|
+
analysis_content(content)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
refresh_analysis
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def follow_tcp_stream(stream_id = nil, format: :ascii, direction: nil)
|
|
135
|
+
return unless document
|
|
136
|
+
stream_id ||= selected_number && document.annotations[selected_number][:tcp_stream]
|
|
137
|
+
return show_error(Vanken::Error.new(t("TCPストリームを持つパケットを選択してください。"))) unless stream_id && stream_id >= 0
|
|
138
|
+
number = Zaniah::UI::NumberInput.new(stream_id.to_s, min: 0, label: t("ストリーム番号")).test_id("vk.follow.number")
|
|
139
|
+
formats = Zaniah::UI::Select.new([["ASCII", :ascii], [t("16進ダンプ"), :hex], [t("生データ(保存用)"), :raw]], value: format, label: t("表示形式"))
|
|
140
|
+
directions = Zaniah::UI::Select.new([[t("両方向"), nil], [t("クライアント → サーバー"), 0], [t("サーバー → クライアント"), 1]], value: direction, label: t("方向"))
|
|
141
|
+
reload = lambda do
|
|
142
|
+
value = number.number
|
|
143
|
+
raise ArgumentError, t("ストリーム番号は0以上の整数で指定してください。") unless value && value >= 0 && value == value.to_i
|
|
144
|
+
follow_tcp_stream(value.to_i, format: format, direction: direction)
|
|
145
|
+
rescue ArgumentError => error
|
|
146
|
+
show_error(error)
|
|
147
|
+
end
|
|
148
|
+
formats.on_change { |value, *_| format = value; reload.call }
|
|
149
|
+
directions.on_change { |value, *_| direction = value; reload.call }
|
|
150
|
+
search = Zaniah::UI::TextField.new("", label: t("文字列検索")).test_id("vk.follow.search")
|
|
151
|
+
controls = Zaniah::Div.new.flex_col.gap(6)
|
|
152
|
+
.child(Zaniah::Div.new.flex_row.gap(6).child(number).child(formats).child(directions)
|
|
153
|
+
.child(Zaniah::UI::Button.new(t("表示")).on_click { reload.call }))
|
|
154
|
+
.child(Zaniah::Div.new.flex_row.gap(6).child(search)
|
|
155
|
+
.child(Zaniah::UI::Button.new(t("次を検索")).on_click { search_stream(search.value) })
|
|
156
|
+
.child(Zaniah::UI::Button.new(t("保存")).test_id("vk.follow.save").on_click { save_stream_dialog(format: format, direction: direction) })
|
|
157
|
+
.child(Zaniah::UI::Button.new(t("このストリームを除外")).test_id("vk.follow.exclude").on_click do
|
|
158
|
+
set_filter("!(tcp.stream == #{stream_id})")
|
|
159
|
+
apply_filter
|
|
160
|
+
dismiss_dialog
|
|
161
|
+
end))
|
|
162
|
+
analysis_dialog(:follow_tcp_stream, t("TCP ストリームを追跡"), controls, reopen: reload)
|
|
163
|
+
@analysis_refresh = lambda do
|
|
164
|
+
analysis_background(->(doc, cancelled) { Gateway::Stream.new(doc, stream_id, cancelled: cancelled) }) do |stream|
|
|
165
|
+
@follow_stream = stream
|
|
166
|
+
chunks = stream.preview(format: format == :raw ? :hex : format, direction: direction)
|
|
167
|
+
runs = chunks.map { |chunk| {text: chunk.text, color: Zaniah::Color.parse(chunk.direction.zero? ? "#ce3e52" : "#277bcc")} }
|
|
168
|
+
@follow_text = Zaniah::UI::RichText.new(runs).test_id("vk.follow.text")
|
|
169
|
+
content = Zaniah::Div.new.flex_col.gap(6).child(Zaniah::UI::Label.new(stream.nodes.join(" ↔ "), wrap: :word))
|
|
170
|
+
if stream.byte_size(direction: direction) > Gateway::Stream::DISPLAY_LIMIT
|
|
171
|
+
content.child(Zaniah::UI::Label.new(t("表示は16 MiBまでです。保存は全量を含みます。"), wrap: :word))
|
|
172
|
+
end
|
|
173
|
+
content.child(Zaniah::ScrollView.new.h(280).child(@follow_text))
|
|
174
|
+
analysis_content(content)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
refresh_analysis
|
|
178
|
+
rescue ArgumentError => error
|
|
179
|
+
show_error(error)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def search_stream(value)
|
|
183
|
+
return if value.empty? || !@follow_text
|
|
184
|
+
index = @follow_text.text.index(value, @follow_text.selection.head)
|
|
185
|
+
index ||= @follow_text.text.index(value)
|
|
186
|
+
return unless index
|
|
187
|
+
@follow_text.selection = Zaniah::TextSelection.new(index, index + value.bytesize)
|
|
188
|
+
@window.request_frame
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def save_stream_dialog(format: :raw, direction: nil)
|
|
192
|
+
return unless @follow_stream
|
|
193
|
+
stream = @follow_stream
|
|
194
|
+
export_path("stream-#{stream.stream_id}.#{format == :raw ? 'bin' : 'txt'}") do |path|
|
|
195
|
+
analysis_background(->(_doc, cancelled) { stream.save(path, format: format, direction: direction, cancelled: cancelled) }) { dismiss_dialog }
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def export_packets = export_dialog(false)
|
|
200
|
+
def export_dissections = export_dialog(true)
|
|
201
|
+
|
|
202
|
+
def export_dialog(dissections = false, scope: :all, format: nil, range_value: "", exclude_ignored: false)
|
|
203
|
+
return unless document
|
|
204
|
+
format ||= dissections ? :json : :pcapng
|
|
205
|
+
range = Zaniah::UI::TextField.new(range_value, label: t("範囲(1-10,20,30-)")).test_id("vk.export.range")
|
|
206
|
+
ignored = Zaniah::UI::Checkbox.new(t("無視したパケットを除外"), value: exclude_ignored)
|
|
207
|
+
scope_control = Zaniah::UI::Select.new([[t("全件"), :all], [t("表示中"), :displayed], [t("選択"), :selected], [t("マーク済み"), :marked], [t("最初と最後のマークの間"), :between_marks], [t("範囲指定"), :range]], value: scope, label: t("対象"))
|
|
208
|
+
.on_change { |value, *_| scope = value }
|
|
209
|
+
formats = dissections ? %i[json ndjson text csv] : %i[pcapng pcap]
|
|
210
|
+
content = Zaniah::Div.new.flex_col.gap(8).child(scope_control).child(range).child(ignored)
|
|
211
|
+
.child(Zaniah::UI::Select.new(formats, value: format, label: t("形式")).on_change { |value, *_| format = value })
|
|
212
|
+
.child(Zaniah::UI::Button.new(t("エクスポート")).test_id("vk.export.save").on_click do
|
|
213
|
+
numbers = Gateway::Exporter.new(document).numbers(scope: scope, range: range.value, selected: selected_number, exclude_ignored: ignored.value)
|
|
214
|
+
columns = @table.columns
|
|
215
|
+
columns = columns.map { |column| column[:key] == :time ? column.merge(format: @preferences.get("packet_list.time_format").to_sym) : column }
|
|
216
|
+
export_path("packets.#{format == :text ? 'txt' : format}") do |path|
|
|
217
|
+
analysis_background(->(doc, cancelled) { Gateway::Exporter.new(doc).write(path, format: format, numbers: numbers, columns: columns, cancelled: cancelled) }) { dismiss_dialog }
|
|
218
|
+
end
|
|
219
|
+
rescue StandardError => error
|
|
220
|
+
show_error(error)
|
|
221
|
+
end)
|
|
222
|
+
analysis_dialog(:export, t("%{kind}のエクスポート", kind: t(dissections ? "解析結果" : "パケット")), content, reopen: -> { export_dialog(dissections, scope: scope, format: format, range_value: range.value, exclude_ignored: ignored.value) })
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def file_properties
|
|
226
|
+
return unless document
|
|
227
|
+
controls = Zaniah::UI::Button.new(t("SHA-256を計算")).test_id("vk.properties.sha256").disabled(!document.path || !File.file?(document.path))
|
|
228
|
+
.on_click do
|
|
229
|
+
analysis_background(->(doc, *) { Digest::SHA256.file(doc.path).hexdigest }) do |digest|
|
|
230
|
+
@analysis_results.child(Zaniah::UI::Label.new("SHA-256: #{digest}", wrap: :word))
|
|
231
|
+
@window.request_frame
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
analysis_dialog(:file_properties, t("キャプチャファイルのプロパティ"), controls, reopen: -> { file_properties })
|
|
235
|
+
@analysis_refresh = lambda do
|
|
236
|
+
analysis_background(->(doc, cancelled) { capture_properties(doc, cancelled) }) do |properties|
|
|
237
|
+
analysis_content(Zaniah::Div.new.flex_col.gap(4).children(properties.map { |label, value| Zaniah::UI::Label.new("#{label}: #{value}", wrap: :word) }))
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
refresh_analysis
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
private
|
|
244
|
+
|
|
245
|
+
def analysis_dialog(kind, title, controls, reopen: nil)
|
|
246
|
+
dismiss_dialog
|
|
247
|
+
@analysis_results = Zaniah::Div.new.flex_col.gap(4).child(Zaniah::UI::Label.new(t("集計中…")))
|
|
248
|
+
@dialog_kind = kind
|
|
249
|
+
@dialog_reopen = reopen
|
|
250
|
+
body = Zaniah::Div.new.flex_col.gap(8).child(controls).child(@analysis_results)
|
|
251
|
+
@dialog = Zaniah::UI::Dialog.new(body, title: title, width: [760, @window.content_size.width - 32].min, close_label: t("閉じる"))
|
|
252
|
+
.on_close { dismiss_dialog }.test_id("vk.#{kind}")
|
|
253
|
+
@window.request_frame
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def analysis_background(operation, &publish)
|
|
257
|
+
return if @analysis_running || @closing || !document
|
|
258
|
+
doc, generation = document, @analysis_generation || 0
|
|
259
|
+
@analysis_running = true
|
|
260
|
+
@analysis_task = @app.executor.background do
|
|
261
|
+
cancelled = -> { @closing || generation != (@analysis_generation || 0) || !document.equal?(doc) }
|
|
262
|
+
next if cancelled.call
|
|
263
|
+
value = operation.call(doc, cancelled)
|
|
264
|
+
@app.executor.post do
|
|
265
|
+
next if cancelled.call
|
|
266
|
+
@analysis_running = false
|
|
267
|
+
publish.call(value)
|
|
268
|
+
end
|
|
269
|
+
rescue StandardError => error
|
|
270
|
+
@app.executor.post do
|
|
271
|
+
next if cancelled.call
|
|
272
|
+
@analysis_running = false
|
|
273
|
+
show_error(error)
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def analysis_content(content)
|
|
279
|
+
@analysis_results.children.clear
|
|
280
|
+
@analysis_results.child(content)
|
|
281
|
+
@window.request_frame
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def analysis_table(rows, keys, &selection)
|
|
285
|
+
labels = {severity: "重大度", code: "コード", protocol: "プロトコル", count: "件数", message: "メッセージ",
|
|
286
|
+
addr_a: "アドレス A", port_a: "ポート A", addr_b: "アドレス B", port_b: "ポート B", packets_ab: "パケット A → B", packets_ba: "パケット B → A", bytes_ab: "バイト A → B", bytes_ba: "バイト B → A", duration_ns: "期間(ns)",
|
|
287
|
+
address: "アドレス", port: "ポート", packets_tx: "送信パケット", packets_rx: "受信パケット", bytes_tx: "送信バイト", bytes_rx: "受信バイト"}
|
|
288
|
+
label = t({expert_info: "エキスパート情報", conversations: "会話", endpoints: "端点"}.fetch(@dialog_kind, "統計"))
|
|
289
|
+
source = AnalysisRows.new(rows.dup)
|
|
290
|
+
table = Zaniah::UI::VirtualTable.new(source, columns: keys.map { |key| {key: key, label: t(labels.fetch(key, key.to_s)), width: key == :message ? 400 : 130} }, height: 300, label: label)
|
|
291
|
+
.on_sort { |key, direction, _| source.sort(key, direction); table.invalidate }
|
|
292
|
+
table.on_select { |index, event, *_| selection.call(source[index], event) } if selection
|
|
293
|
+
table
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def statistics_table(statistics)
|
|
297
|
+
rows = statistics.rows
|
|
298
|
+
columns = statistics.kind == :conv ? %i[addr_a port_a addr_b port_b packets_ab packets_ba bytes_ab bytes_ba duration_ns] : %i[address port packets_tx packets_rx bytes_tx bytes_rx]
|
|
299
|
+
selected = nil
|
|
300
|
+
table = analysis_table(rows, columns) { |row, _| selected = row }
|
|
301
|
+
content = Zaniah::Div.new.flex_col.gap(6)
|
|
302
|
+
.child(Zaniah::ScrollView.new(axis: :horizontal).h(300).child(table.w(columns.size * 130)))
|
|
303
|
+
content.child(Zaniah::UI::Label.new(t("メモリ上限により%{count}行が省略されました。", count: statistics.evicted), wrap: :word)) if statistics.evicted.positive?
|
|
304
|
+
content.child(Zaniah::Div.new.flex_row.gap(6)
|
|
305
|
+
.child(Zaniah::UI::Button.new(t("フィルタとして適用")).on_click do
|
|
306
|
+
next unless selected
|
|
307
|
+
set_filter(statistics_filter(statistics, selected))
|
|
308
|
+
apply_filter
|
|
309
|
+
dismiss_dialog
|
|
310
|
+
end)
|
|
311
|
+
.child(Zaniah::UI::Button.new(t("TCP ストリームを追跡")).disabled(statistics.type != :tcp).on_click do
|
|
312
|
+
next unless selected && statistics.type == :tcp
|
|
313
|
+
expression = statistics_filter(statistics, selected)
|
|
314
|
+
doc = document
|
|
315
|
+
program = Core::DisplayFilter.compile(expression, catalog: doc.catalog)
|
|
316
|
+
analysis_background(->(*) { (1..doc.count).find { |number| program.match?(doc.view(number)) } }) do |number|
|
|
317
|
+
follow_tcp_stream(doc.annotations[number][:tcp_stream]) if number
|
|
318
|
+
end
|
|
319
|
+
end))
|
|
320
|
+
content
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def statistics_filter(table, row)
|
|
324
|
+
protocol = table.type.to_s
|
|
325
|
+
if table.kind == :endpoints
|
|
326
|
+
address = %i[tcp udp].include?(table.type) ? (row.address.include?(":") ? "ipv6" : "ip") : protocol
|
|
327
|
+
expression = "#{address}.addr == #{row.address}"
|
|
328
|
+
return %i[tcp udp].include?(table.type) ? "#{expression} && #{protocol}.port == #{row.port}" : expression
|
|
329
|
+
end
|
|
330
|
+
address = %i[tcp udp].include?(table.type) ? (row.addr_a.include?(":") ? "ipv6" : "ip") : protocol
|
|
331
|
+
forward = "#{address}.src == #{row.addr_a} && #{address}.dst == #{row.addr_b}"
|
|
332
|
+
backward = "#{address}.src == #{row.addr_b} && #{address}.dst == #{row.addr_a}"
|
|
333
|
+
if %i[tcp udp].include?(table.type)
|
|
334
|
+
forward += " && #{protocol}.srcport == #{row.port_a} && #{protocol}.dstport == #{row.port_b}"
|
|
335
|
+
backward += " && #{protocol}.srcport == #{row.port_b} && #{protocol}.dstport == #{row.port_a}"
|
|
336
|
+
end
|
|
337
|
+
"(#{forward}) || (#{backward})"
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def export_path(default_name, &operation)
|
|
341
|
+
return path_dialog(t("エクスポート先"), &operation) if @backend == :tui
|
|
342
|
+
path = @window.prompt_for_paths(save: true, default_name: default_name)&.first
|
|
343
|
+
operation.call(path) if path
|
|
344
|
+
rescue StandardError => error
|
|
345
|
+
show_error(error)
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def capture_properties(doc, cancelled)
|
|
349
|
+
count, bytes, captured, first, last = doc.count, 0, 0, nil, nil
|
|
350
|
+
interfaces = Hash.new(0)
|
|
351
|
+
(1..count).each do |number|
|
|
352
|
+
raise Vanken::Error, "operation cancelled" if cancelled.call
|
|
353
|
+
metadata = doc.store.metadata(number)
|
|
354
|
+
bytes += metadata[:original_length]
|
|
355
|
+
captured += metadata[:caplen]
|
|
356
|
+
first = [first || metadata[:timestamp_ns], metadata[:timestamp_ns]].min
|
|
357
|
+
last = [last || metadata[:timestamp_ns], metadata[:timestamp_ns]].max
|
|
358
|
+
interfaces[metadata[:interface]&.fetch("name", nil) || t("未記録")] += 1
|
|
359
|
+
end
|
|
360
|
+
duration = first && last ? (last - first) / 1e9 : 0
|
|
361
|
+
values = {t("ファイル") => doc.path || t("未保存"), t("パケット") => count, t("期間(秒)") => duration,
|
|
362
|
+
t("合計(bytes)") => bytes, t("保存済み(bytes)") => captured,
|
|
363
|
+
t("平均pps") => duration.positive? ? (count / duration).round(3) : 0,
|
|
364
|
+
t("平均bps") => duration.positive? ? (bytes * 8 / duration).round(3) : 0}
|
|
365
|
+
interfaces.each { |name, packets| values[t("インタフェース %{name}", name: name)] = t("%{count} パケット", count: packets) }
|
|
366
|
+
doc.capture_stats.each { |key, value| values[t("取得統計 %{key}", key: key)] = value }
|
|
367
|
+
values
|
|
368
|
+
end
|
|
369
|
+
end
|
|
370
|
+
end
|
|
371
|
+
end
|
|
@@ -11,34 +11,52 @@ require_relative "selection"
|
|
|
11
11
|
require_relative "dialogs"
|
|
12
12
|
require_relative "actions"
|
|
13
13
|
require_relative "packet_source"
|
|
14
|
+
require_relative "navigation_operations"
|
|
15
|
+
require_relative "coloring_operations"
|
|
16
|
+
require_relative "column_operations"
|
|
17
|
+
require_relative "settings_operations"
|
|
18
|
+
require_relative "analysis_dialogs"
|
|
14
19
|
require_relative "../gateway/interfaces"
|
|
15
20
|
require_relative "../gateway/capture_filter"
|
|
21
|
+
require_relative "../gateway/resolver"
|
|
16
22
|
require_relative "../app/capture_controller"
|
|
17
23
|
require_relative "../config/log"
|
|
24
|
+
require_relative "../config/messages"
|
|
25
|
+
require_relative "../config/sessions"
|
|
18
26
|
|
|
19
27
|
module Vanken
|
|
20
28
|
module UI
|
|
21
29
|
class Application
|
|
22
30
|
include FileOperations, FilterOperations, Selection, Dialogs
|
|
31
|
+
include NavigationOperations, ColoringOperations
|
|
32
|
+
include ColumnOperations
|
|
33
|
+
include SettingsOperations
|
|
34
|
+
include AnalysisDialogs
|
|
23
35
|
attr_reader :app, :window, :preferences, :table, :tree, :hex, :filter_field, :capture,
|
|
24
|
-
:dialog_kind, :packet_source, :interface_infos
|
|
36
|
+
:dialog_kind, :packet_source, :interface_infos, :interface_traffic
|
|
25
37
|
|
|
26
|
-
def initialize(backend: nil, preferences:
|
|
27
|
-
@
|
|
38
|
+
def initialize(backend: nil, preferences: nil, debug: false, session_parent: nil)
|
|
39
|
+
@profiles = Config::Profiles.new(directory: preferences&.directory || Config::Paths.config)
|
|
40
|
+
@preferences = preferences ||= @profiles.preferences
|
|
28
41
|
@log = Config::Log.open(debug: debug, directory: File.join(preferences.directory, "logs"))
|
|
29
42
|
@app = Zaniah::App.new
|
|
30
43
|
@entity = @app.new_entity { {document: nil, number: nil, node: nil, details: [], bytes: "".b, error: nil} }
|
|
31
44
|
@selection_generation, @file_generation, @filter_generation = 0, 0, 0
|
|
32
45
|
@pool_mutex = Mutex.new
|
|
46
|
+
@resolver_mutex = Mutex.new
|
|
47
|
+
@resolution_changes = {}
|
|
33
48
|
@autoscroll = false
|
|
34
49
|
@backend = backend || (RUBY_PLATFORM.match?(/darwin/) ? :mac : RUBY_PLATFORM.match?(/mingw|mswin/) ? :windows : :linux)
|
|
35
|
-
@
|
|
50
|
+
@session_parent = session_parent || Dir.tmpdir
|
|
51
|
+
@filter_field = Zaniah::UI::TextField.new("", placeholder: t("表示フィルタ"), clearable: false).test_id("vk.filter.input")
|
|
36
52
|
@filter_field.completion(CompletionProvider.new(self)).on_change { |text, _| validate_filter(text) }
|
|
37
53
|
@packet_source = PacketSource.new(self)
|
|
38
54
|
@capture = App::CaptureController.new(preferences: preferences,
|
|
39
55
|
on_document: ->(document) { @app.executor.post { attach_document(document) unless @closing } },
|
|
40
56
|
on_update: ->(*) { @app.executor.post { changed unless @closing } })
|
|
41
57
|
@window = @app.open_window(backend: @backend, width: preferences.get("layout.width"), height: preferences.get("layout.height"), title: "Vanken")
|
|
58
|
+
coloring_rules
|
|
59
|
+
column_settings
|
|
42
60
|
unless @backend == :tui
|
|
43
61
|
@window.text_system = Zaniah::TextSystem::Renderer.new
|
|
44
62
|
@window.text_system.scale_factor = @window.scale_factor
|
|
@@ -59,6 +77,7 @@ module Vanken
|
|
|
59
77
|
change_theme(preferences.get("appearance.theme"), persist: false)
|
|
60
78
|
@main_view = MainView.new(self).test_id("vk.main")
|
|
61
79
|
@window.draw { @main_view }
|
|
80
|
+
start_interface_monitor unless @backend == :headless
|
|
62
81
|
show_error(Vanken::ConfigError.new(preferences.warning)) if preferences.warning
|
|
63
82
|
@app.executor.background do
|
|
64
83
|
infos = Gateway::Interfaces.list
|
|
@@ -71,9 +90,16 @@ module Vanken
|
|
|
71
90
|
rescue StandardError => error
|
|
72
91
|
@app.executor.post { @interface_error = error.message; changed }
|
|
73
92
|
end
|
|
93
|
+
if @backend != :headless || session_parent
|
|
94
|
+
@app.executor.background do
|
|
95
|
+
sessions = Config::Sessions.candidates(parent: @session_parent)
|
|
96
|
+
@app.executor.post { recovery_dialog(sessions) if !@closing && !sessions.empty? && !@dialog }
|
|
97
|
+
end
|
|
98
|
+
end
|
|
74
99
|
end
|
|
75
100
|
|
|
76
101
|
def document = @app.read(@entity)[:document]
|
|
102
|
+
def t(text, **values) = Config::Messages.translate(text, language: @preferences.get("appearance.language"), **values)
|
|
77
103
|
def selected_number = @app.read(@entity)[:number]
|
|
78
104
|
def selected_node = @app.read(@entity)[:node]
|
|
79
105
|
def detail_nodes = @app.read(@entity)[:details]
|
|
@@ -83,6 +109,19 @@ module Vanken
|
|
|
83
109
|
end
|
|
84
110
|
def changed
|
|
85
111
|
return if @closing
|
|
112
|
+
apply_analysis_settings
|
|
113
|
+
if @reanalysis_in_flight && @reanalysis_task&.done? && document&.complete?
|
|
114
|
+
@reanalysis_in_flight = false
|
|
115
|
+
@packet_source.reset
|
|
116
|
+
end
|
|
117
|
+
if @reanalysis_selection && document&.complete? && !@reanalysis_in_flight
|
|
118
|
+
saved_doc, number, generation = @reanalysis_selection
|
|
119
|
+
@reanalysis_selection = nil
|
|
120
|
+
number = selected_number if generation != @selection_generation
|
|
121
|
+
select_packet(number) if document.equal?(saved_doc) && number && number <= document.count
|
|
122
|
+
end
|
|
123
|
+
begin_reanalysis if @pending_reanalysis
|
|
124
|
+
refresh_analysis
|
|
86
125
|
@packet_source.refresh do
|
|
87
126
|
next if @closing
|
|
88
127
|
error = document&.error || @capture.error
|
|
@@ -96,9 +135,15 @@ module Vanken
|
|
|
96
135
|
end
|
|
97
136
|
def attach_document(value)
|
|
98
137
|
return if @closing
|
|
138
|
+
wait_reanalysis
|
|
139
|
+
@pending_reanalysis = @reanalysis_selection = nil
|
|
140
|
+
@reanalysis_in_flight = false
|
|
141
|
+
close_analysis
|
|
142
|
+
cancel_packet_search
|
|
99
143
|
value.scanner = ->(payload) { filter_pool.submit(payload) } if value
|
|
100
144
|
@selection_generation += 1
|
|
101
145
|
update { |state| state.merge!(document: value, number: nil, node: nil, details: [], bytes: "".b) }
|
|
146
|
+
configure_document_columns if value
|
|
102
147
|
@packet_source.reset
|
|
103
148
|
@tree&.replace([])
|
|
104
149
|
@hex.bytes = "".b if @hex
|
|
@@ -108,9 +153,13 @@ module Vanken
|
|
|
108
153
|
@table.scroll_to(0) if @table && value&.displayed_count&.positive? && !@autoscroll
|
|
109
154
|
@window.title = "Vanken#{value&.path ? " — #{File.basename(value.path)}" : ""}" if @window.respond_to?(:title=)
|
|
110
155
|
end
|
|
111
|
-
def run
|
|
156
|
+
def run
|
|
157
|
+
return @app.run unless @backend == :tui
|
|
158
|
+
@window.on_tick { @app.executor.drain }
|
|
159
|
+
@window.run
|
|
160
|
+
end
|
|
112
161
|
def native? = !%i[headless tui].include?(@backend)
|
|
113
|
-
def monospace_font = @window.text_system
|
|
162
|
+
def monospace_font = @window.text_system.respond_to?(:font) ? @window.text_system.font : nil
|
|
114
163
|
def install_panes(table, tree, hex) = (@table, @tree, @hex = table, tree, hex)
|
|
115
164
|
def filter_pool
|
|
116
165
|
@pool_mutex.synchronize do
|
|
@@ -125,6 +174,11 @@ module Vanken
|
|
|
125
174
|
def close
|
|
126
175
|
return if @closed
|
|
127
176
|
@closing = true
|
|
177
|
+
stop_interface_monitor
|
|
178
|
+
reset_resolver
|
|
179
|
+
wait_reanalysis
|
|
180
|
+
close_analysis
|
|
181
|
+
cancel_packet_search
|
|
128
182
|
@packet_source.reset
|
|
129
183
|
@selection_generation += 1
|
|
130
184
|
@filter_generation += 1
|
|
@@ -147,6 +201,7 @@ module Vanken
|
|
|
147
201
|
theme = theme.with(typography: theme.typography.with(size_md: size, size_sm: size - 1, size_xs: size - 2))
|
|
148
202
|
@app.global(:theme, theme)
|
|
149
203
|
@preferences.set("appearance.theme", name) if persist
|
|
204
|
+
@packet_source.reset
|
|
150
205
|
@window.request_frame
|
|
151
206
|
end
|
|
152
207
|
def zoom(delta = nil)
|
|
@@ -187,19 +242,138 @@ module Vanken
|
|
|
187
242
|
end
|
|
188
243
|
def status_text
|
|
189
244
|
doc = document
|
|
190
|
-
return "キャプチャファイルを開くか、インタフェースを選んで開始してください" unless doc
|
|
245
|
+
return t("キャプチャファイルを開くか、インタフェースを選んで開始してください") unless doc
|
|
191
246
|
dropped = @capture.stats.fetch("dropped", @capture.stats.fetch(:dropped, 0))
|
|
192
247
|
total = doc.store.durable_count
|
|
193
|
-
"
|
|
248
|
+
t("%{label} %{count} / %{total} 表示 %{displayed} ドロップ %{dropped}%{progress}",
|
|
249
|
+
label: t(doc.loading? ? "読み込み中" : "パケット"), count: @packet_source.packet_count, total: total,
|
|
250
|
+
displayed: @packet_source.count, dropped: dropped, progress: doc.progress ? " #{(doc.progress * 100).round}%" : "")
|
|
194
251
|
end
|
|
195
252
|
def copy_text(text) = @window.write_clipboard([Zaniah::Clipboard::Item.new({"text/plain" => text})])
|
|
196
253
|
def show_error(error)
|
|
197
254
|
@log.error("#{error.class}: #{error.message}")
|
|
198
255
|
@last_error = error
|
|
256
|
+
dismiss_dialog
|
|
199
257
|
@dialog_kind = :error
|
|
200
|
-
@
|
|
258
|
+
@dialog_reopen = -> { show_error(error) }
|
|
259
|
+
@dialog = Zaniah::UI::Dialog.new(Zaniah::UI::Label.new(error.message, wrap: :word), title: t("操作を完了できません"), close_label: t("閉じる")).test_id("vk.error")
|
|
260
|
+
.on_close { dismiss_dialog }
|
|
201
261
|
@window.request_frame
|
|
202
262
|
end
|
|
263
|
+
|
|
264
|
+
def start_interface_monitor
|
|
265
|
+
return if @traffic_thread
|
|
266
|
+
@traffic_mutex, @traffic_condition = Mutex.new, ConditionVariable.new
|
|
267
|
+
@traffic_thread = Thread.new do
|
|
268
|
+
previous, history = nil, {}
|
|
269
|
+
loop do
|
|
270
|
+
break if @closing
|
|
271
|
+
sample = Gateway::Interfaces.sample_traffic(previous: previous, history: history)
|
|
272
|
+
previous, history = sample.values_at(:previous, :history)
|
|
273
|
+
post = @traffic_mutex.synchronize do
|
|
274
|
+
@traffic_sample = sample
|
|
275
|
+
!@traffic_posted && (@traffic_posted = true)
|
|
276
|
+
end
|
|
277
|
+
if post
|
|
278
|
+
@app.executor.post do
|
|
279
|
+
value = @traffic_mutex.synchronize { @traffic_posted = false; @traffic_sample }
|
|
280
|
+
next if @closing
|
|
281
|
+
@interface_traffic = value
|
|
282
|
+
@app.update(@entity) { |_state, cx| cx.notify }
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
@traffic_mutex.synchronize { @traffic_condition.wait(@traffic_mutex, 1) unless @closing }
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def stop_interface_monitor
|
|
291
|
+
return unless @traffic_thread
|
|
292
|
+
@traffic_mutex.synchronize { @traffic_condition.broadcast }
|
|
293
|
+
@traffic_thread.join
|
|
294
|
+
@traffic_thread = nil
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def resolve_address(address, document:, number:)
|
|
298
|
+
resolver = @resolver_mutex.synchronize do
|
|
299
|
+
next if @closing || !@preferences.get("name_resolution.enabled")
|
|
300
|
+
@resolver ||= Gateway::Resolver.new(timeout: @preferences.get("name_resolution.timeout"), limit: @preferences.get("name_resolution.cache_size"))
|
|
301
|
+
end
|
|
302
|
+
return address unless resolver
|
|
303
|
+
resolver.request(address) do |_name|
|
|
304
|
+
post = @resolver_mutex.synchronize do
|
|
305
|
+
next false if @closing || !resolver.equal?(@resolver)
|
|
306
|
+
(@resolution_changes[document] ||= Set.new).add(number)
|
|
307
|
+
!@resolution_posted && (@resolution_posted = true)
|
|
308
|
+
end
|
|
309
|
+
next unless post
|
|
310
|
+
@app.executor.post do
|
|
311
|
+
changes = @resolver_mutex.synchronize do
|
|
312
|
+
saved, @resolution_changes = @resolution_changes, {}
|
|
313
|
+
@resolution_posted = false
|
|
314
|
+
saved
|
|
315
|
+
end
|
|
316
|
+
next if @closing
|
|
317
|
+
numbers = changes[self.document]
|
|
318
|
+
@packet_source.invalidate_rows(numbers) if numbers && !numbers.empty?
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def reset_resolver
|
|
324
|
+
old = @resolver_mutex.synchronize do
|
|
325
|
+
previous, @resolver = @resolver, nil
|
|
326
|
+
@resolution_changes = {}
|
|
327
|
+
@resolution_posted = false
|
|
328
|
+
previous
|
|
329
|
+
end
|
|
330
|
+
return unless old
|
|
331
|
+
@closing ? old.close : @app.executor.background { old.close }
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def recovery_dialog(sessions)
|
|
335
|
+
content = Zaniah::Div.new.flex_col.gap(8)
|
|
336
|
+
sessions.each do |directory|
|
|
337
|
+
content.child(Zaniah::Div.new.flex_col.gap(4)
|
|
338
|
+
.child(Zaniah::UI::Label.new(directory, wrap: :word))
|
|
339
|
+
.child(Zaniah::Div.new.flex_row.gap(8)
|
|
340
|
+
.child(Zaniah::UI::Button.new(t("復旧")).on_click { recover_session(directory) })
|
|
341
|
+
.child(Zaniah::UI::Button.new(t("破棄"), variant: :danger).on_click do
|
|
342
|
+
Config::Sessions.discard(directory, parent: @session_parent)
|
|
343
|
+
remaining = sessions.reject { |value| value == directory }
|
|
344
|
+
remaining.empty? ? dismiss_dialog : recovery_dialog(remaining)
|
|
345
|
+
rescue StandardError => error
|
|
346
|
+
show_error(error)
|
|
347
|
+
end)))
|
|
348
|
+
end
|
|
349
|
+
show_dialog(:recovery, t("前回のキャプチャを復旧"), Zaniah::ScrollView.new.h(240).child(content), reopen: -> { recovery_dialog(sessions) })
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
def recover_session(directory)
|
|
353
|
+
request_destructive do
|
|
354
|
+
previous = document
|
|
355
|
+
attach_document(nil)
|
|
356
|
+
dismiss_dialog
|
|
357
|
+
@file_generation += 1
|
|
358
|
+
generation = @file_generation
|
|
359
|
+
preferences = @preferences
|
|
360
|
+
@app.executor.background do
|
|
361
|
+
previous&.close
|
|
362
|
+
doc = Config::Sessions.recover(directory, parent: @session_parent, preferences: preferences,
|
|
363
|
+
on_update: ->(updated) { @app.executor.post { changed if !@closing && document.equal?(updated) } })
|
|
364
|
+
@app.executor.post do
|
|
365
|
+
if !@closing && generation == @file_generation
|
|
366
|
+
attach_document(doc)
|
|
367
|
+
changed
|
|
368
|
+
else
|
|
369
|
+
doc.close
|
|
370
|
+
end
|
|
371
|
+
end
|
|
372
|
+
rescue StandardError => error
|
|
373
|
+
@app.executor.post { show_error(error) if !@closing && generation == @file_generation }
|
|
374
|
+
end
|
|
375
|
+
end
|
|
376
|
+
end
|
|
203
377
|
end
|
|
204
378
|
end
|
|
205
379
|
end
|