vanken 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +84 -0
- data/docs/filters.md +25 -0
- data/docs/performance.md +113 -0
- data/docs/releases.md +15 -0
- data/docs/upstream.md +32 -0
- data/exe/vanken +5 -0
- data/exe/vanken-capture +6 -0
- data/lib/vanken/app/capture_controller.rb +197 -0
- data/lib/vanken/app/document.rb +305 -0
- data/lib/vanken/app/document_jobs.rb +199 -0
- data/lib/vanken/capture/analyzer.rb +44 -0
- data/lib/vanken/capture/analyzer_process.rb +91 -0
- data/lib/vanken/capture/analyzer_wire.rb +42 -0
- data/lib/vanken/capture/analyzer_worker.rb +153 -0
- data/lib/vanken/capture/control_protocol.rb +32 -0
- data/lib/vanken/capture/filter_worker.rb +180 -0
- data/lib/vanken/capture/helper_main.rb +137 -0
- data/lib/vanken/capture/helper_options.rb +70 -0
- data/lib/vanken/capture/launcher.rb +181 -0
- data/lib/vanken/capture/privileges.rb +24 -0
- data/lib/vanken/capture/receiver.rb +39 -0
- data/lib/vanken/cli.rb +68 -0
- data/lib/vanken/config/log.rb +19 -0
- data/lib/vanken/config/preferences.rb +126 -0
- data/lib/vanken/core/display_filter/compiler.rb +108 -0
- data/lib/vanken/core/display_filter/errors.rb +19 -0
- data/lib/vanken/core/display_filter/field_resolver.rb +86 -0
- data/lib/vanken/core/display_filter/lexer.rb +124 -0
- data/lib/vanken/core/display_filter/parser.rb +157 -0
- data/lib/vanken/core/display_filter/types.rb +176 -0
- data/lib/vanken/core/frame.rb +33 -0
- data/lib/vanken/core/frame_store.rb +179 -0
- data/lib/vanken/core/frame_view.rb +68 -0
- data/lib/vanken/core/stores.rb +192 -0
- data/lib/vanken/errors.rb +9 -0
- data/lib/vanken/gateway/capture_filter.rb +30 -0
- data/lib/vanken/gateway/detail_builder.rb +63 -0
- data/lib/vanken/gateway/dissector.rb +176 -0
- data/lib/vanken/gateway/file_reader.rb +153 -0
- data/lib/vanken/gateway/file_writer.rb +64 -0
- data/lib/vanken/gateway/interfaces.rb +30 -0
- data/lib/vanken/gateway/live_capture.rb +79 -0
- data/lib/vanken/gateway/packet_snapshot.rb +89 -0
- data/lib/vanken/ui/actions.rb +50 -0
- data/lib/vanken/ui/application.rb +205 -0
- data/lib/vanken/ui/dialogs.rb +89 -0
- data/lib/vanken/ui/file_operations.rb +113 -0
- data/lib/vanken/ui/filter_operations.rb +120 -0
- data/lib/vanken/ui/main_view.rb +113 -0
- data/lib/vanken/ui/packet_source.rb +161 -0
- data/lib/vanken/ui/selection.rb +91 -0
- data/lib/vanken/version.rb +5 -0
- data/lib/vanken.rb +17 -0
- data/packaging/README.md +33 -0
- data/sig/generated/vanken/app/capture_controller.rbs +58 -0
- data/sig/generated/vanken/app/document.rbs +117 -0
- data/sig/generated/vanken/app/document_jobs.rbs +27 -0
- data/sig/generated/vanken/capture/analyzer.rbs +11 -0
- data/sig/generated/vanken/capture/analyzer_process.rbs +17 -0
- data/sig/generated/vanken/capture/analyzer_wire.rbs +19 -0
- data/sig/generated/vanken/capture/analyzer_worker.rbs +45 -0
- data/sig/generated/vanken/capture/control_protocol.rbs +17 -0
- data/sig/generated/vanken/capture/filter_worker.rbs +51 -0
- data/sig/generated/vanken/capture/helper_main.rbs +23 -0
- data/sig/generated/vanken/capture/helper_options.rbs +27 -0
- data/sig/generated/vanken/capture/launcher.rbs +60 -0
- data/sig/generated/vanken/capture/privileges.rbs +12 -0
- data/sig/generated/vanken/capture/receiver.rbs +15 -0
- data/sig/generated/vanken/cli.rbs +9 -0
- data/sig/generated/vanken/config/log.rbs +9 -0
- data/sig/generated/vanken/config/preferences.rbs +49 -0
- data/sig/generated/vanken/core/display_filter/compiler.rbs +45 -0
- data/sig/generated/vanken/core/display_filter/errors.rbs +16 -0
- data/sig/generated/vanken/core/display_filter/field_resolver.rbs +48 -0
- data/sig/generated/vanken/core/display_filter/lexer.rbs +49 -0
- data/sig/generated/vanken/core/display_filter/parser.rbs +95 -0
- data/sig/generated/vanken/core/display_filter/types.rbs +45 -0
- data/sig/generated/vanken/core/frame.rbs +63 -0
- data/sig/generated/vanken/core/frame_store.rbs +65 -0
- data/sig/generated/vanken/core/frame_view.rbs +36 -0
- data/sig/generated/vanken/core/stores.rbs +110 -0
- data/sig/generated/vanken/errors.rbs +18 -0
- data/sig/generated/vanken/gateway/capture_filter.rbs +17 -0
- data/sig/generated/vanken/gateway/detail_builder.rbs +21 -0
- data/sig/generated/vanken/gateway/dissector.rbs +113 -0
- data/sig/generated/vanken/gateway/file_reader.rbs +53 -0
- data/sig/generated/vanken/gateway/file_writer.rbs +27 -0
- data/sig/generated/vanken/gateway/interfaces.rbs +16 -0
- data/sig/generated/vanken/gateway/live_capture.rbs +48 -0
- data/sig/generated/vanken/gateway/packet_snapshot.rbs +48 -0
- data/sig/generated/vanken/ui/actions.rbs +9 -0
- data/sig/generated/vanken/ui/application.rbs +85 -0
- data/sig/generated/vanken/ui/dialogs.rbs +21 -0
- data/sig/generated/vanken/ui/file_operations.rbs +25 -0
- data/sig/generated/vanken/ui/filter_operations.rbs +33 -0
- data/sig/generated/vanken/ui/main_view.rbs +21 -0
- data/sig/generated/vanken/ui/packet_source.rbs +40 -0
- data/sig/generated/vanken/ui/selection.rbs +23 -0
- data/sig/generated/vanken/version.rbs +5 -0
- data/sig/vanken.rbs +25 -0
- metadata +192 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Vanken
|
|
4
|
+
module UI
|
|
5
|
+
module Dialogs
|
|
6
|
+
def dialog = @dialog
|
|
7
|
+
def dismiss_dialog
|
|
8
|
+
@dialog&.close
|
|
9
|
+
@dialog = @dialog_kind = @pending_destructive = nil
|
|
10
|
+
@window.request_frame
|
|
11
|
+
end
|
|
12
|
+
def show_dialog(kind, title, content)
|
|
13
|
+
dismiss_dialog
|
|
14
|
+
@dialog_kind = kind
|
|
15
|
+
@dialog = Zaniah::UI::Dialog.new(content, title: title).on_close { dismiss_dialog }.test_id("vk.#{kind}")
|
|
16
|
+
@window.request_frame
|
|
17
|
+
end
|
|
18
|
+
def path_dialog(title, &operation)
|
|
19
|
+
field = Zaniah::UI::TextField.new("", label: title).test_id("vk.path.input")
|
|
20
|
+
content = Zaniah::Div.new.flex_col.gap(12).child(field)
|
|
21
|
+
.child(Zaniah::Div.new.flex_row.gap(8)
|
|
22
|
+
.child(Zaniah::UI::Button.new("OK").on_click do
|
|
23
|
+
next if field.value.empty?
|
|
24
|
+
operation.call(field.value)
|
|
25
|
+
rescue StandardError => error
|
|
26
|
+
show_error(error)
|
|
27
|
+
end)
|
|
28
|
+
.child(Zaniah::UI::Button.new("キャンセル", variant: :secondary).on_click { dismiss_dialog }))
|
|
29
|
+
show_dialog(:path, title, content)
|
|
30
|
+
end
|
|
31
|
+
def capture_options(interface: nil)
|
|
32
|
+
fields = {}
|
|
33
|
+
infos = @interface_infos || []
|
|
34
|
+
choices = infos.map { |item| ["#{item.fetch(:name)} #{item[:description]}", item.fetch(:name)] }
|
|
35
|
+
selected = choices.any? { |choice| choice.last == interface } ? interface : choices.first&.last
|
|
36
|
+
selector = Zaniah::UI::Select.new(choices, label: "インタフェース", value: selected)
|
|
37
|
+
selector.on_change { |value, *_| selected = value }
|
|
38
|
+
content = Zaniah::Div.new.flex_col.gap(10).child(selector)
|
|
39
|
+
[["filter", "キャプチャフィルタ (BPF)", ""], ["snaplen", "Snaplen", @preferences.get("capture.snaplen").to_s],
|
|
40
|
+
["buffer_size", "バッファサイズ (bytes)", @preferences.get("capture.buffer_size").to_s]].each do |key, label, value|
|
|
41
|
+
fields[key] = Zaniah::UI::TextField.new(value, label: label)
|
|
42
|
+
content.child(fields[key])
|
|
43
|
+
end
|
|
44
|
+
promiscuous = Zaniah::UI::Checkbox.new("プロミスキャスモード", value: @preferences.get("capture.promiscuous"))
|
|
45
|
+
content.child(promiscuous)
|
|
46
|
+
direction = @preferences.get("capture.direction")
|
|
47
|
+
content.child(Zaniah::UI::Select.new([["送受信", "inout"], ["受信", "in"], ["送信", "out"]], label: "方向", value: direction).on_change { |value, *_| direction = value })
|
|
48
|
+
status = Zaniah::UI::Label.new(infos.empty? ? (@interface_error || "インタフェースを取得中です。") : "GUI は通常ユーザーで動作し、取得ヘルパーに必要な権限を渡します。", wrap: :word, tone: :muted)
|
|
49
|
+
content.child(status)
|
|
50
|
+
content.child(Zaniah::UI::Button.new("開始").test_id("vk.capture.start").disabled(infos.empty?).on_click do
|
|
51
|
+
options = {"interface" => selected, "snaplen" => Integer(fields.fetch("snaplen").value, 10),
|
|
52
|
+
"buffer_size" => Integer(fields.fetch("buffer_size").value, 10), "filter" => fields.fetch("filter").value,
|
|
53
|
+
"promiscuous" => promiscuous.value, "direction" => direction, "backend" => @preferences.get("capture.backend")}
|
|
54
|
+
# Compilation and privileged helper startup both happen away from the UI thread.
|
|
55
|
+
@app.executor.background do
|
|
56
|
+
metadata = infos.find { |item| item[:name] == selected }
|
|
57
|
+
Gateway::CaptureFilter.compile(options["filter"], linktype: metadata.fetch(:linktype), live: RUBY_PLATFORM.include?("linux"), snaplen: options["snaplen"]) unless options["filter"].empty?
|
|
58
|
+
@app.executor.post { start_capture(options) unless @closing }
|
|
59
|
+
rescue StandardError => error
|
|
60
|
+
@app.executor.post { show_error(error) unless @closing }
|
|
61
|
+
end
|
|
62
|
+
rescue ArgumentError => error
|
|
63
|
+
show_error(error)
|
|
64
|
+
end)
|
|
65
|
+
show_dialog(:capture_options, "キャプチャオプション", content)
|
|
66
|
+
end
|
|
67
|
+
def preferences_dialog
|
|
68
|
+
theme = Zaniah::UI::Select.new(%w[system dark light high_contrast], label: "テーマ", value: @preferences.get("appearance.theme"))
|
|
69
|
+
.on_change { |value, *_| change_theme(value) }
|
|
70
|
+
time = Zaniah::UI::Select.new(%w[relative absolute delta delta_displayed epoch], label: "時刻表示", value: @preferences.get("packet_list.time_format"))
|
|
71
|
+
.on_change { |value, *_| time_format(value) }
|
|
72
|
+
content = Zaniah::Div.new.flex_col.gap(12).child(theme).child(time)
|
|
73
|
+
.child(Zaniah::Div.new.flex_row.gap(8).child(Zaniah::UI::Button.new("文字を小さく").on_click { zoom(-1) })
|
|
74
|
+
.child(Zaniah::UI::Button.new("文字を大きく").on_click { zoom(1) }))
|
|
75
|
+
show_dialog(:preferences, "設定", content)
|
|
76
|
+
end
|
|
77
|
+
def columns_dialog
|
|
78
|
+
content = Zaniah::Div.new.flex_col.gap(8)
|
|
79
|
+
@table.columns.each_with_index do |column, index|
|
|
80
|
+
content.child(Zaniah::Div.new.flex_row.gap(8)
|
|
81
|
+
.child(Zaniah::UI::Checkbox.new(column[:label], value: column[:visible]).on_change { |value, *_| @table.column_visible(column[:key], value) }.flex_1)
|
|
82
|
+
.child(Zaniah::UI::Button.new("↑", size: :sm).disabled(index.zero?).on_click { @table.move_column(column[:key], index - 1); columns_dialog })
|
|
83
|
+
.child(Zaniah::UI::Button.new("↓", size: :sm).disabled(index == @table.columns.size - 1).on_click { @table.move_column(column[:key], index + 1); columns_dialog }))
|
|
84
|
+
end
|
|
85
|
+
show_dialog(:columns, "表示する列", content)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Vanken
|
|
4
|
+
module UI
|
|
5
|
+
module FileOperations
|
|
6
|
+
CAPTURE_FILTERS = [{label: "Capture files", patterns: %w[*.pcapng *.pcap]}].freeze
|
|
7
|
+
|
|
8
|
+
def open_dialog
|
|
9
|
+
if @backend == :tui
|
|
10
|
+
path_dialog("ファイルを開く") { |path| open_file(path) }
|
|
11
|
+
else
|
|
12
|
+
paths = @window.prompt_for_paths(filters: CAPTURE_FILTERS)
|
|
13
|
+
open_file(paths.first) if paths && !paths.empty?
|
|
14
|
+
end
|
|
15
|
+
rescue StandardError => error
|
|
16
|
+
show_error(error)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def open_file(path)
|
|
20
|
+
request_destructive do
|
|
21
|
+
@file_generation += 1
|
|
22
|
+
generation = @file_generation
|
|
23
|
+
previous = document
|
|
24
|
+
attach_document(nil)
|
|
25
|
+
@app.executor.background do
|
|
26
|
+
previous&.close
|
|
27
|
+
doc = App::Document.new(preferences: @preferences, on_update: ->(updated) { @app.executor.post { changed if document.equal?(updated) } })
|
|
28
|
+
@app.executor.post do
|
|
29
|
+
if generation == @file_generation && !@closing
|
|
30
|
+
attach_document(doc)
|
|
31
|
+
@preferences.remember_file(path)
|
|
32
|
+
else
|
|
33
|
+
doc.close
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
doc.open(path)
|
|
37
|
+
if generation != @file_generation || @closing
|
|
38
|
+
doc.close
|
|
39
|
+
end
|
|
40
|
+
rescue StandardError => error
|
|
41
|
+
@app.executor.post { show_error(error) if generation == @file_generation && !@closing }
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def save_dialog(after: nil)
|
|
47
|
+
return unless document
|
|
48
|
+
path = @backend == :tui ? nil : @window.prompt_for_paths(save: true,
|
|
49
|
+
default_name: "capture.pcapng", filters: CAPTURE_FILTERS)&.first
|
|
50
|
+
return path_dialog("名前を付けて保存") { |value| save_file(value, after: after) } if @backend == :tui
|
|
51
|
+
save_file(path, after: after) if path
|
|
52
|
+
rescue StandardError => error
|
|
53
|
+
show_error(error)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def save_file(path, after: nil)
|
|
57
|
+
path += ".pcapng" if File.extname(path).empty?
|
|
58
|
+
doc = document
|
|
59
|
+
@app.executor.background do
|
|
60
|
+
doc.save(path).wait_for_save
|
|
61
|
+
@app.executor.post do
|
|
62
|
+
next if @closing || !document.equal?(doc)
|
|
63
|
+
doc.error ? show_error(doc.error) : (dismiss_dialog; after&.call; changed)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def close_document
|
|
69
|
+
request_destructive do
|
|
70
|
+
old = document
|
|
71
|
+
@file_generation += 1
|
|
72
|
+
attach_document(nil)
|
|
73
|
+
@app.executor.background { @capture.stop; @capture.wait; old&.close }
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def reload_file = document&.source == :file && document.path && open_file(document.path)
|
|
78
|
+
|
|
79
|
+
def request_destructive(&operation)
|
|
80
|
+
if @capture.running?
|
|
81
|
+
@dialog_kind = :capturing
|
|
82
|
+
content = Zaniah::Div.new.gap(12).child(Zaniah::UI::Label.new("キャプチャを停止してから操作してください。"))
|
|
83
|
+
.child(Zaniah::UI::Button.new("停止").on_click do
|
|
84
|
+
@capture.stop
|
|
85
|
+
dismiss_dialog
|
|
86
|
+
@app.executor.background { @capture.wait; @app.executor.post { request_destructive(&operation) unless @closing } }
|
|
87
|
+
end)
|
|
88
|
+
@dialog = Zaniah::UI::Dialog.new(content, title: "キャプチャ中").test_id("vk.capture.confirm")
|
|
89
|
+
@window.request_frame
|
|
90
|
+
return false
|
|
91
|
+
end
|
|
92
|
+
return operation.call unless document&.dirty?
|
|
93
|
+
@pending_destructive = operation
|
|
94
|
+
@dialog_kind = :unsaved
|
|
95
|
+
content = Zaniah::Div.new.flex_col.gap(12)
|
|
96
|
+
.child(Zaniah::UI::Label.new("このキャプチャはまだ保存されていません。"))
|
|
97
|
+
.child(Zaniah::Div.new.flex_row.gap(8)
|
|
98
|
+
.child(Zaniah::UI::Button.new("保存").test_id("vk.unsaved.save").on_click { save_dialog(after: @pending_destructive) })
|
|
99
|
+
.child(Zaniah::UI::Button.new("破棄", variant: :danger).test_id("vk.unsaved.discard").on_click { discard_changes })
|
|
100
|
+
.child(Zaniah::UI::Button.new("キャンセル", variant: :secondary).test_id("vk.unsaved.cancel").on_click { dismiss_dialog }))
|
|
101
|
+
@dialog = Zaniah::UI::Dialog.new(content, title: "キャプチャを保存しますか?", close_on_scrim: false).test_id("vk.unsaved")
|
|
102
|
+
@window.request_frame
|
|
103
|
+
false
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def discard_changes
|
|
107
|
+
action = @pending_destructive
|
|
108
|
+
dismiss_dialog
|
|
109
|
+
action&.call
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Vanken
|
|
4
|
+
module UI
|
|
5
|
+
class CompletionProvider
|
|
6
|
+
def initialize(ui) = @ui = ui
|
|
7
|
+
def complete(text, caret)
|
|
8
|
+
before = text.byteslice(0, caret)
|
|
9
|
+
prefix = before[/[a-zA-Z_][\w.]*\z/] || ""
|
|
10
|
+
items = []
|
|
11
|
+
if caret == text.bytesize && before == prefix
|
|
12
|
+
saved = @ui.preferences.bookmarks.to_a + @ui.preferences.history.map { |expression| [expression, expression] }
|
|
13
|
+
items = saved.select { |label, expression| label.start_with?(prefix) || expression.start_with?(prefix) }
|
|
14
|
+
.map { |label, expression| {label: label, insert_text: expression} }
|
|
15
|
+
end
|
|
16
|
+
names = @ui.document&.catalog&.names || Gateway::FieldCatalog.new.names
|
|
17
|
+
names += %w[frame.number frame.len frame.cap_len frame.time_relative frame.marked tcp.stream tcp.port udp.port ip.addr ipv6.addr expert.severity expert.code and or not contains matches in]
|
|
18
|
+
items += names.uniq.grep(/^#{Regexp.escape(prefix)}/).map { |name| {label: name} } unless prefix.empty?
|
|
19
|
+
return nil if items.empty?
|
|
20
|
+
Zaniah::UI::Completion.new(range: (caret - prefix.bytesize)...caret, items: items.first(40))
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
module FilterOperations
|
|
25
|
+
def set_filter(text)
|
|
26
|
+
@filter_field.buffer.replace(0...@filter_field.buffer.bytesize, text)
|
|
27
|
+
validate_filter(text)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def validate_filter(text)
|
|
31
|
+
@filter_generation += 1
|
|
32
|
+
generation = @filter_generation
|
|
33
|
+
catalog = document&.catalog
|
|
34
|
+
@validation_task&.cancel
|
|
35
|
+
@validation_task = @app.executor.background do
|
|
36
|
+
next unless generation == @filter_generation && !@closing
|
|
37
|
+
sleep(0.15)
|
|
38
|
+
next unless generation == @filter_generation && !@closing
|
|
39
|
+
begin
|
|
40
|
+
program = Core::DisplayFilter.compile(text, catalog: catalog)
|
|
41
|
+
status = text.empty? ? :none : program.warnings.empty? ? :success : :warning
|
|
42
|
+
message = program.warnings.join("\n")
|
|
43
|
+
rescue Core::DisplayFilter::SyntaxError => error
|
|
44
|
+
status = :error
|
|
45
|
+
message = "#{error.message} (位置 #{error.position + 1})"
|
|
46
|
+
end
|
|
47
|
+
@app.executor.post do
|
|
48
|
+
@filter_field.status(status, message: message) if generation == @filter_generation && !@closing
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def apply_filter
|
|
54
|
+
doc = document
|
|
55
|
+
return unless doc
|
|
56
|
+
expression = @filter_field.value
|
|
57
|
+
doc.apply_filter(expression)
|
|
58
|
+
doc.sort(@table.sort_key, @table.sort_direction) if @table&.sort_key
|
|
59
|
+
@preferences.remember_filter(expression) unless expression.empty?
|
|
60
|
+
@packet_source.reset
|
|
61
|
+
@window.request_frame
|
|
62
|
+
rescue Core::DisplayFilter::SyntaxError => error
|
|
63
|
+
@filter_field.status(:error, message: "#{error.message} (位置 #{error.position + 1})")
|
|
64
|
+
end
|
|
65
|
+
def initial_filter(text)
|
|
66
|
+
set_filter(text)
|
|
67
|
+
@pending_filter = true
|
|
68
|
+
changed if document&.complete?
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def clear_filter
|
|
72
|
+
set_filter("")
|
|
73
|
+
apply_filter
|
|
74
|
+
end
|
|
75
|
+
def filter_from_selection(mode = :selected, prepare: false)
|
|
76
|
+
filter_from_expression(selected_node&.filter, mode, prepare: prepare)
|
|
77
|
+
end
|
|
78
|
+
def filter_from_expression(expression, mode = :selected, prepare: false)
|
|
79
|
+
return unless expression
|
|
80
|
+
negative = mode.to_s.include?("not")
|
|
81
|
+
expression = "!(#{expression})" if negative
|
|
82
|
+
if mode.to_s.start_with?("and", "or") && !@filter_field.value.empty?
|
|
83
|
+
expression = "(#{@filter_field.value}) #{mode.to_s.start_with?('and') ? '&&' : '||'} (#{expression})"
|
|
84
|
+
end
|
|
85
|
+
set_filter(expression)
|
|
86
|
+
apply_filter unless prepare
|
|
87
|
+
end
|
|
88
|
+
def selection_menu(expression)
|
|
89
|
+
return Zaniah::Menu.new([]) unless expression
|
|
90
|
+
%i[filter prepare].each do |kind|
|
|
91
|
+
%i[selected not and and_not or or_not].each do |mode|
|
|
92
|
+
name = :"#{kind}_#{mode}"
|
|
93
|
+
@app.actions.register(:"context_#{name}", title: @app.actions.command(name).title) do
|
|
94
|
+
filter_from_expression(expression, mode, prepare: kind == :prepare)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
@app.actions.register(:context_copy_filter, title: "フィルタとしてコピー") { copy_text(expression) }
|
|
99
|
+
Zaniah::Menu.build do
|
|
100
|
+
submenu("フィルタとして適用") { %i[selected not and and_not or or_not].each { |mode| item(:"context_filter_#{mode}") } }
|
|
101
|
+
submenu("フィルタを準備") { %i[selected not and and_not or or_not].each { |mode| item(:"context_prepare_#{mode}") } }
|
|
102
|
+
item(:context_copy_filter)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
def bookmark_filter
|
|
106
|
+
path_dialog("フィルタの名前") do |name|
|
|
107
|
+
@preferences.bookmark(name, @filter_field.value) unless name.empty?
|
|
108
|
+
dismiss_dialog
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
def filter_history
|
|
112
|
+
items = @preferences.bookmarks.map { |name, expression| [name, expression] } + @preferences.history.map { |expression| [expression, expression] }
|
|
113
|
+
content = Zaniah::Div.new.flex_col.gap(4).children(items.first(60).map do |label, expression|
|
|
114
|
+
Zaniah::UI::Button.new(label, variant: :ghost).on_click { set_filter(expression); apply_filter; dismiss_dialog }
|
|
115
|
+
end)
|
|
116
|
+
show_dialog(:history, "表示フィルタ", content)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Vanken
|
|
4
|
+
module UI
|
|
5
|
+
class MainView < Zaniah::UI::Component
|
|
6
|
+
COLUMNS = [[:no, "No.", 64], [:time, "Time", 128], [:source, "Source", 164],
|
|
7
|
+
[:destination, "Destination", 164], [:protocol, "Protocol", 90], [:length, "Length", 76], [:info, "Info", 560]].freeze
|
|
8
|
+
|
|
9
|
+
def initialize(ui)
|
|
10
|
+
super()
|
|
11
|
+
@ui = ui
|
|
12
|
+
columns = COLUMNS.map { |key, label, width| {key: key, label: label, width: width, align: %i[no time length].include?(key) ? :end : :start} }
|
|
13
|
+
saved = ui.preferences.get("layout.columns")
|
|
14
|
+
unless saved.empty?
|
|
15
|
+
columns = columns.sort_by { |column| saved.index { |item| item["key"] == column[:key].to_s } || columns.size }.map do |column|
|
|
16
|
+
value = saved.find { |item| item["key"] == column[:key].to_s }
|
|
17
|
+
value ? column.merge(width: value["width"], visible: value["visible"]) : column
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
table = Zaniah::UI::VirtualTable.new(ui.packet_source, columns: columns, follow_tail: ui.autoscroll?)
|
|
21
|
+
.test_id("vk.packet_list").on_select { |index, *_| number = ui.packet_source.row_id(index); ui.select_packet(number) if number.is_a?(Integer) }
|
|
22
|
+
.on_sort { |key, direction, _| ui.document&.sort(key, direction) }
|
|
23
|
+
.on_columns_change { |items, _| ui.preferences.set("layout.columns", items.map { |item| {"key" => item[:key].to_s, "width" => item[:width], "visible" => item[:visible]} }) }
|
|
24
|
+
tree = Zaniah::UI::TreeView.new([]).test_id("vk.packet_details").on_select { |node, *_| ui.select_detail(node) }
|
|
25
|
+
.on_context_menu { |node, _| ui.selection_menu(node.filter) }
|
|
26
|
+
table.on_row_context_menu do |index, _|
|
|
27
|
+
number = ui.packet_source.row_id(index)
|
|
28
|
+
table.select(index) if number.is_a?(Integer)
|
|
29
|
+
ui.selection_menu(number.is_a?(Integer) ? "frame.number == #{number}" : nil)
|
|
30
|
+
end
|
|
31
|
+
hex = Zaniah::UI::HexView.new("".b).test_id("vk.packet_bytes").on_select { |range, _| ui.select_bytes(range) }
|
|
32
|
+
.on_copy { |range, _| ui.copy_text(ui.selected_bytes.byteslice(range).unpack1("H*").scan(/../).join(" ")) }
|
|
33
|
+
ui.install_panes(table, tree, hex)
|
|
34
|
+
ratios = ui.preferences.get("layout.ratios")
|
|
35
|
+
@lower = Zaniah::UI::SplitPane.new(pane("Packet details", tree), pane("Packet bytes", hex), ratio: ratios[1])
|
|
36
|
+
.on_change { |ratio, _| ratios[1] = ratio; ui.preferences.set("layout.ratios", ratios.dup) }
|
|
37
|
+
@split = Zaniah::UI::SplitPane.new(table, @lower, orientation: :vertical, ratio: ratios[0])
|
|
38
|
+
.on_change { |ratio, _| ratios[0] = ratio; ui.preferences.set("layout.ratios", ratios.dup) }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def build(cx)
|
|
42
|
+
ui = @ui
|
|
43
|
+
doc = ui.document
|
|
44
|
+
doc.frame_latency = cx.window.frame_stats.fetch(:frame_ms, 0) / 1000 if doc
|
|
45
|
+
height = [cx.window.content_size.height - 132, 120].max
|
|
46
|
+
table_height = [height * @split.ratio, 48].max
|
|
47
|
+
lower_height = [(height * (1 - @split.ratio)) - 32, 32].max
|
|
48
|
+
heights = [table_height, lower_height]
|
|
49
|
+
if heights != @heights
|
|
50
|
+
ui.table.viewport_height = table_height
|
|
51
|
+
ui.tree.viewport_height = lower_height
|
|
52
|
+
ui.hex.viewport_height = lower_height
|
|
53
|
+
@heights = heights
|
|
54
|
+
end
|
|
55
|
+
root = Zaniah::Div.new.w_full.h_full.flex_col.bg(cx.theme.colors.background)
|
|
56
|
+
root.child(Zaniah::UI::MenuBar.from(ui.app.menu_bar)) unless RUBY_PLATFORM.include?("darwin") && ui.native?
|
|
57
|
+
toolbar_items = [
|
|
58
|
+
button("開く", "vk.open") { ui.open_dialog },
|
|
59
|
+
button("保存", "vk.save", disabled: !doc) { ui.save_dialog },
|
|
60
|
+
button(ui.capture.running? ? "停止" : "開始", "vk.capture") { ui.capture_toggle },
|
|
61
|
+
button("再開", "vk.restart", disabled: !ui.capture.options) { ui.restart_capture },
|
|
62
|
+
button(ui.autoscroll? ? "末尾追従 ✓" : "末尾追従", "vk.autoscroll") { ui.toggle_autoscroll }]
|
|
63
|
+
if doc&.loading? && doc.source == :file
|
|
64
|
+
toolbar_items << button("読み込みを中止", "vk.file.cancel") { doc.cancel }
|
|
65
|
+
end
|
|
66
|
+
root.child(Zaniah::UI::Toolbar.new(*toolbar_items))
|
|
67
|
+
root.child(Zaniah::Div.new.flex_row.items_center.gap(6).p([4, 8])
|
|
68
|
+
.child(Zaniah::Div.new.flex_1.focusable(context: {in_display_filter: true}).child(ui.filter_field)).child(button("適用", "vk.filter.apply", disabled: !doc) { ui.apply_filter })
|
|
69
|
+
.child(button("クリア", "vk.filter.clear") { ui.clear_filter })
|
|
70
|
+
.child(button("履歴", "vk.filter.history") { ui.filter_history }))
|
|
71
|
+
root.child(doc ? @split.flex_1 : welcome(cx).flex_1)
|
|
72
|
+
root.child(Zaniah::UI::StatusBar.new(Zaniah::UI::Label.new(ui.status_text, size: :xs)).test_id("vk.status"))
|
|
73
|
+
root.child(ui.dialog) if ui.dialog
|
|
74
|
+
root.on_drop(types: ["text/uri-list"]) do |event, _|
|
|
75
|
+
event.content.formats.fetch("text/uri-list", "").each_line do |line|
|
|
76
|
+
next if line.start_with?("#") || line.strip.empty?
|
|
77
|
+
uri = URI.parse(line.strip)
|
|
78
|
+
next unless uri.scheme == "file" && [nil, "", "localhost"].include?(uri.host) && !uri.query && !uri.fragment
|
|
79
|
+
path = URI::DEFAULT_PARSER.unescape(uri.path).force_encoding(Encoding::UTF_8)
|
|
80
|
+
next unless path.start_with?("/") && path.valid_encoding? && !path.include?("\0")
|
|
81
|
+
ui.open_file(path)
|
|
82
|
+
break
|
|
83
|
+
end
|
|
84
|
+
true
|
|
85
|
+
rescue URI::InvalidURIError
|
|
86
|
+
false
|
|
87
|
+
end
|
|
88
|
+
root
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
def pane(title, body) = Zaniah::Div.new.w_full.h_full.flex_col.child(Zaniah::UI::Label.new(title, size: :xs, tone: :muted).p([4, 8])).child(body)
|
|
94
|
+
def button(label, id, disabled: false, &block) = Zaniah::UI::Button.new(label, size: :sm, variant: :secondary).disabled(disabled).test_id(id).on_click(&block)
|
|
95
|
+
def welcome(_cx)
|
|
96
|
+
content = Zaniah::Div.new.flex_col.p(24).gap(12).test_id("vk.welcome")
|
|
97
|
+
.child(Zaniah::UI::Label.new("Vanken", size: :xl))
|
|
98
|
+
.child(Zaniah::UI::Label.new("パケットキャプチャを開くか、インタフェースを選択してキャプチャを開始します。", tone: :muted, wrap: :word))
|
|
99
|
+
.child(Zaniah::UI::Button.new("キャプチャファイルを開く").on_click { @ui.open_dialog })
|
|
100
|
+
.child(Zaniah::UI::Button.new("インタフェースを選択").on_click { @ui.capture_options })
|
|
101
|
+
@ui.preferences.recent_files.first(10).each do |path|
|
|
102
|
+
content.child(Zaniah::UI::Button.new(path, size: :sm, variant: :ghost).on_click { @ui.open_file(path) })
|
|
103
|
+
end
|
|
104
|
+
(@ui.interface_infos || []).each do |info|
|
|
105
|
+
name = info.fetch(:name)
|
|
106
|
+
content.child(Zaniah::UI::Button.new("#{name} #{info[:description]}", size: :sm, variant: :ghost)
|
|
107
|
+
.on_click { @ui.capture_options(interface: name) })
|
|
108
|
+
end
|
|
109
|
+
content
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Vanken
|
|
4
|
+
module UI
|
|
5
|
+
class PacketSource
|
|
6
|
+
def initialize(ui)
|
|
7
|
+
@ui = ui
|
|
8
|
+
reset
|
|
9
|
+
end
|
|
10
|
+
def reset
|
|
11
|
+
@document = @ui.document
|
|
12
|
+
@generation = (@generation || 0) + 1
|
|
13
|
+
@rows, @pending, @requests = {}, {}, {}
|
|
14
|
+
@batch_scheduled = false
|
|
15
|
+
@refresh_pending = @refresh_again = nil
|
|
16
|
+
@time_format = @ui.preferences.get("packet_list.time_format")
|
|
17
|
+
@precision = {"milli" => 3, "micro" => 6, "nano" => 9}.fetch(@ui.preferences.get("packet_list.time_precision"))
|
|
18
|
+
@limit = @ui.preferences.get("packet_list.row_cache_rows")
|
|
19
|
+
refresh
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Called by posted document notifications, never once per rendered cell.
|
|
23
|
+
def refresh(&ready)
|
|
24
|
+
return reset.tap { ready&.call } unless @document.equal?(@ui.document)
|
|
25
|
+
count, packet_count = @document&.displayed_count || 0, @document&.count || 0
|
|
26
|
+
range = ready && @count && count > @count && @ui.autoscroll? && @ui.table&.body&.visible_range
|
|
27
|
+
if range && range.size.positive?
|
|
28
|
+
if @refresh_pending
|
|
29
|
+
@refresh_again = ready
|
|
30
|
+
else
|
|
31
|
+
refresh_tail(count, packet_count, range.size + 2, ready)
|
|
32
|
+
end
|
|
33
|
+
else
|
|
34
|
+
@refresh_pending = @refresh_again = nil
|
|
35
|
+
publish_counts(count, packet_count)
|
|
36
|
+
ready&.call
|
|
37
|
+
end
|
|
38
|
+
self
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def count = @count
|
|
42
|
+
def packet_count = @packet_count
|
|
43
|
+
def row_id(index)
|
|
44
|
+
return [:pending, index] unless index.is_a?(Integer) && index.between?(0, @count - 1)
|
|
45
|
+
@numbers[index] ||= @document.number_at(index)
|
|
46
|
+
rescue IndexError
|
|
47
|
+
[:pending, index]
|
|
48
|
+
end
|
|
49
|
+
def index_of(id) = @numbers.key(id) || @document&.display_numbers&.index(id)
|
|
50
|
+
def cell(index, key)
|
|
51
|
+
text = value(index, key)
|
|
52
|
+
return nil if text.nil?
|
|
53
|
+
theme = @ui.app.global(:theme)
|
|
54
|
+
Zaniah::Text.new(text.to_s, font: @ui.monospace_font, size: theme.typography.size_sm, color: theme.colors.text)
|
|
55
|
+
end
|
|
56
|
+
def value(index, key)
|
|
57
|
+
number = row_id(index)
|
|
58
|
+
return "" unless number.is_a?(Integer)
|
|
59
|
+
return number.to_s if key == :no
|
|
60
|
+
request_row(number, index) unless @rows.key?(number) || @pending.key?(number)
|
|
61
|
+
@rows[number]&.fetch(key, "")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def publish_counts(count, packet_count)
|
|
67
|
+
@count, @packet_count, @numbers = count, packet_count, {}
|
|
68
|
+
if @time_format == "delta_displayed"
|
|
69
|
+
@generation += 1
|
|
70
|
+
@rows, @pending, @requests = {}, {}, {}
|
|
71
|
+
@batch_scheduled = false
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def refresh_tail(count, packet_count, size, ready)
|
|
76
|
+
document, generation, format, precision = @document, @generation, @time_format, @precision
|
|
77
|
+
first = [count - size, 0].max
|
|
78
|
+
numbers = (first...count).to_h { |index| [index, document.number_at(index)] }
|
|
79
|
+
previous = format == "delta_displayed" && first.positive? && document.number_at(first - 1)
|
|
80
|
+
@refresh_pending = numbers
|
|
81
|
+
@ui.app.executor.background do
|
|
82
|
+
rows = numbers.to_h do |index, number|
|
|
83
|
+
values = row_values(document, number, index, format, precision, previous)
|
|
84
|
+
previous = number
|
|
85
|
+
[number, values]
|
|
86
|
+
end
|
|
87
|
+
@ui.app.executor.post do
|
|
88
|
+
next unless @refresh_pending.equal?(numbers) && generation == @generation && @ui.document.equal?(document)
|
|
89
|
+
again, @refresh_again, @refresh_pending = @refresh_again, nil, nil
|
|
90
|
+
publish_counts(count, packet_count)
|
|
91
|
+
@numbers = numbers
|
|
92
|
+
rows.each do |number, values|
|
|
93
|
+
@pending.delete(number)
|
|
94
|
+
@rows[number] = values if values
|
|
95
|
+
end
|
|
96
|
+
@rows.shift while @rows.size > @limit
|
|
97
|
+
if again && document.displayed_count == count && document.count == packet_count
|
|
98
|
+
ready, again = again, nil
|
|
99
|
+
end
|
|
100
|
+
published_generation = @generation
|
|
101
|
+
ready.call
|
|
102
|
+
refresh(&again) if again && published_generation == @generation && @ui.document.equal?(document)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
rescue IndexError
|
|
106
|
+
refresh
|
|
107
|
+
ready.call
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def request_row(number, index)
|
|
111
|
+
@pending[number] = @generation
|
|
112
|
+
@requests[number] = index
|
|
113
|
+
return if @batch_scheduled
|
|
114
|
+
@batch_scheduled = true
|
|
115
|
+
generation = @generation
|
|
116
|
+
@ui.app.executor.post { flush_rows if generation == @generation && @document.equal?(@ui.document) }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def flush_rows
|
|
120
|
+
document, generation, format, precision = @document, @generation, @time_format, @precision
|
|
121
|
+
requests, @requests = @requests, {}
|
|
122
|
+
@batch_scheduled = false
|
|
123
|
+
@ui.app.executor.background do
|
|
124
|
+
rows = requests.to_h do |number, index|
|
|
125
|
+
[number, row_values(document, number, index, format, precision)]
|
|
126
|
+
end
|
|
127
|
+
@ui.app.executor.post do
|
|
128
|
+
next unless generation == @generation && @ui.document.equal?(document)
|
|
129
|
+
rows.each do |number, values|
|
|
130
|
+
@pending.delete(number)
|
|
131
|
+
@rows[number] = values if values
|
|
132
|
+
end
|
|
133
|
+
@rows.shift while @rows.size > @limit
|
|
134
|
+
@ui.window.request_frame if rows.any? { |_, values| values }
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def row_values(document, number, index, format, precision, previous = :current)
|
|
140
|
+
row = document.row(number)
|
|
141
|
+
values = row.slice(:source, :destination, :protocol, :length, :info).transform_values(&:to_s)
|
|
142
|
+
values[:time] = format_time(document, row, number, index, format, precision, previous)
|
|
143
|
+
values
|
|
144
|
+
rescue StandardError
|
|
145
|
+
nil
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def format_time(document, row, number, index, format, precision, previous = :current)
|
|
149
|
+
timestamp = row.fetch(:timestamp_ns)
|
|
150
|
+
return Time.at(timestamp / 1_000_000_000, timestamp % 1_000_000_000, :nsec).strftime("%H:%M:%S.%#{precision}N") if format == "absolute"
|
|
151
|
+
value = if format == "delta_displayed"
|
|
152
|
+
previous = index.positive? && document.number_at(index - 1) if previous == :current
|
|
153
|
+
previous ? (timestamp - document.store.metadata(previous)[:timestamp_ns]) / 1e9 : 0.0
|
|
154
|
+
else
|
|
155
|
+
document.time_value(number, format.to_sym)
|
|
156
|
+
end
|
|
157
|
+
Kernel.format("%.*f", precision, value)
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|