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.
Files changed (104) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +5 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +84 -0
  5. data/docs/filters.md +25 -0
  6. data/docs/performance.md +113 -0
  7. data/docs/releases.md +15 -0
  8. data/docs/upstream.md +32 -0
  9. data/exe/vanken +5 -0
  10. data/exe/vanken-capture +6 -0
  11. data/lib/vanken/app/capture_controller.rb +197 -0
  12. data/lib/vanken/app/document.rb +305 -0
  13. data/lib/vanken/app/document_jobs.rb +199 -0
  14. data/lib/vanken/capture/analyzer.rb +44 -0
  15. data/lib/vanken/capture/analyzer_process.rb +91 -0
  16. data/lib/vanken/capture/analyzer_wire.rb +42 -0
  17. data/lib/vanken/capture/analyzer_worker.rb +153 -0
  18. data/lib/vanken/capture/control_protocol.rb +32 -0
  19. data/lib/vanken/capture/filter_worker.rb +180 -0
  20. data/lib/vanken/capture/helper_main.rb +137 -0
  21. data/lib/vanken/capture/helper_options.rb +70 -0
  22. data/lib/vanken/capture/launcher.rb +181 -0
  23. data/lib/vanken/capture/privileges.rb +24 -0
  24. data/lib/vanken/capture/receiver.rb +39 -0
  25. data/lib/vanken/cli.rb +68 -0
  26. data/lib/vanken/config/log.rb +19 -0
  27. data/lib/vanken/config/preferences.rb +126 -0
  28. data/lib/vanken/core/display_filter/compiler.rb +108 -0
  29. data/lib/vanken/core/display_filter/errors.rb +19 -0
  30. data/lib/vanken/core/display_filter/field_resolver.rb +86 -0
  31. data/lib/vanken/core/display_filter/lexer.rb +124 -0
  32. data/lib/vanken/core/display_filter/parser.rb +157 -0
  33. data/lib/vanken/core/display_filter/types.rb +176 -0
  34. data/lib/vanken/core/frame.rb +33 -0
  35. data/lib/vanken/core/frame_store.rb +179 -0
  36. data/lib/vanken/core/frame_view.rb +68 -0
  37. data/lib/vanken/core/stores.rb +192 -0
  38. data/lib/vanken/errors.rb +9 -0
  39. data/lib/vanken/gateway/capture_filter.rb +30 -0
  40. data/lib/vanken/gateway/detail_builder.rb +63 -0
  41. data/lib/vanken/gateway/dissector.rb +176 -0
  42. data/lib/vanken/gateway/file_reader.rb +153 -0
  43. data/lib/vanken/gateway/file_writer.rb +64 -0
  44. data/lib/vanken/gateway/interfaces.rb +30 -0
  45. data/lib/vanken/gateway/live_capture.rb +79 -0
  46. data/lib/vanken/gateway/packet_snapshot.rb +89 -0
  47. data/lib/vanken/ui/actions.rb +50 -0
  48. data/lib/vanken/ui/application.rb +205 -0
  49. data/lib/vanken/ui/dialogs.rb +89 -0
  50. data/lib/vanken/ui/file_operations.rb +113 -0
  51. data/lib/vanken/ui/filter_operations.rb +120 -0
  52. data/lib/vanken/ui/main_view.rb +113 -0
  53. data/lib/vanken/ui/packet_source.rb +161 -0
  54. data/lib/vanken/ui/selection.rb +91 -0
  55. data/lib/vanken/version.rb +5 -0
  56. data/lib/vanken.rb +17 -0
  57. data/packaging/README.md +33 -0
  58. data/sig/generated/vanken/app/capture_controller.rbs +58 -0
  59. data/sig/generated/vanken/app/document.rbs +117 -0
  60. data/sig/generated/vanken/app/document_jobs.rbs +27 -0
  61. data/sig/generated/vanken/capture/analyzer.rbs +11 -0
  62. data/sig/generated/vanken/capture/analyzer_process.rbs +17 -0
  63. data/sig/generated/vanken/capture/analyzer_wire.rbs +19 -0
  64. data/sig/generated/vanken/capture/analyzer_worker.rbs +45 -0
  65. data/sig/generated/vanken/capture/control_protocol.rbs +17 -0
  66. data/sig/generated/vanken/capture/filter_worker.rbs +51 -0
  67. data/sig/generated/vanken/capture/helper_main.rbs +23 -0
  68. data/sig/generated/vanken/capture/helper_options.rbs +27 -0
  69. data/sig/generated/vanken/capture/launcher.rbs +60 -0
  70. data/sig/generated/vanken/capture/privileges.rbs +12 -0
  71. data/sig/generated/vanken/capture/receiver.rbs +15 -0
  72. data/sig/generated/vanken/cli.rbs +9 -0
  73. data/sig/generated/vanken/config/log.rbs +9 -0
  74. data/sig/generated/vanken/config/preferences.rbs +49 -0
  75. data/sig/generated/vanken/core/display_filter/compiler.rbs +45 -0
  76. data/sig/generated/vanken/core/display_filter/errors.rbs +16 -0
  77. data/sig/generated/vanken/core/display_filter/field_resolver.rbs +48 -0
  78. data/sig/generated/vanken/core/display_filter/lexer.rbs +49 -0
  79. data/sig/generated/vanken/core/display_filter/parser.rbs +95 -0
  80. data/sig/generated/vanken/core/display_filter/types.rbs +45 -0
  81. data/sig/generated/vanken/core/frame.rbs +63 -0
  82. data/sig/generated/vanken/core/frame_store.rbs +65 -0
  83. data/sig/generated/vanken/core/frame_view.rbs +36 -0
  84. data/sig/generated/vanken/core/stores.rbs +110 -0
  85. data/sig/generated/vanken/errors.rbs +18 -0
  86. data/sig/generated/vanken/gateway/capture_filter.rbs +17 -0
  87. data/sig/generated/vanken/gateway/detail_builder.rbs +21 -0
  88. data/sig/generated/vanken/gateway/dissector.rbs +113 -0
  89. data/sig/generated/vanken/gateway/file_reader.rbs +53 -0
  90. data/sig/generated/vanken/gateway/file_writer.rbs +27 -0
  91. data/sig/generated/vanken/gateway/interfaces.rbs +16 -0
  92. data/sig/generated/vanken/gateway/live_capture.rbs +48 -0
  93. data/sig/generated/vanken/gateway/packet_snapshot.rbs +48 -0
  94. data/sig/generated/vanken/ui/actions.rbs +9 -0
  95. data/sig/generated/vanken/ui/application.rbs +85 -0
  96. data/sig/generated/vanken/ui/dialogs.rbs +21 -0
  97. data/sig/generated/vanken/ui/file_operations.rbs +25 -0
  98. data/sig/generated/vanken/ui/filter_operations.rbs +33 -0
  99. data/sig/generated/vanken/ui/main_view.rbs +21 -0
  100. data/sig/generated/vanken/ui/packet_source.rbs +40 -0
  101. data/sig/generated/vanken/ui/selection.rbs +23 -0
  102. data/sig/generated/vanken/version.rbs +5 -0
  103. data/sig/vanken.rbs +25 -0
  104. 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