vanken 0.1.0 → 0.3.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 (105) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +24 -0
  3. data/README.md +28 -11
  4. data/data/coloring_rules.yml +67 -0
  5. data/docs/performance.md +53 -0
  6. data/docs/releases.md +2 -4
  7. data/docs/usage.md +51 -0
  8. data/exe/vanken-setup-permissions +6 -0
  9. data/lib/vanken/app/capture_controller.rb +66 -1
  10. data/lib/vanken/app/document.rb +102 -39
  11. data/lib/vanken/app/document_analysis.rb +75 -0
  12. data/lib/vanken/app/document_jobs.rb +52 -9
  13. data/lib/vanken/app/expert_info.rb +26 -0
  14. data/lib/vanken/app/io_graph.rb +47 -0
  15. data/lib/vanken/app/navigation.rb +77 -0
  16. data/lib/vanken/app/search_job.rb +62 -0
  17. data/lib/vanken/app/stats_job.rb +45 -0
  18. data/lib/vanken/capture/analyzer.rb +3 -1
  19. data/lib/vanken/capture/analyzer_process.rb +9 -6
  20. data/lib/vanken/capture/analyzer_worker.rb +2 -2
  21. data/lib/vanken/capture/filter_worker.rb +8 -2
  22. data/lib/vanken/capture/helper_main.rb +10 -3
  23. data/lib/vanken/capture/helper_options.rb +6 -2
  24. data/lib/vanken/capture/launcher.rb +1 -0
  25. data/lib/vanken/capture/permission_setup.rb +201 -0
  26. data/lib/vanken/config/analysis_settings.rb +58 -0
  27. data/lib/vanken/config/columns.rb +138 -0
  28. data/lib/vanken/config/messages.rb +105 -0
  29. data/lib/vanken/config/preferences.rb +34 -8
  30. data/lib/vanken/config/profiles.rb +74 -0
  31. data/lib/vanken/config/sessions.rb +69 -0
  32. data/lib/vanken/config/yaml_file.rb +36 -0
  33. data/lib/vanken/core/coloring.rb +86 -0
  34. data/lib/vanken/core/frame_store.rb +2 -2
  35. data/lib/vanken/core/stores.rb +12 -0
  36. data/lib/vanken/gateway/detail_builder.rb +1 -1
  37. data/lib/vanken/gateway/display_capture_filter.rb +102 -0
  38. data/lib/vanken/gateway/dissector.rb +31 -6
  39. data/lib/vanken/gateway/exporter.rb +125 -0
  40. data/lib/vanken/gateway/file_writer.rb +26 -2
  41. data/lib/vanken/gateway/interfaces.rb +59 -0
  42. data/lib/vanken/gateway/resolver.rb +131 -0
  43. data/lib/vanken/gateway/statistics.rb +83 -0
  44. data/lib/vanken/gateway/stream.rb +159 -0
  45. data/lib/vanken/ui/actions.rb +54 -28
  46. data/lib/vanken/ui/analysis_dialogs.rb +371 -0
  47. data/lib/vanken/ui/application.rb +183 -9
  48. data/lib/vanken/ui/coloring_operations.rb +104 -0
  49. data/lib/vanken/ui/column_operations.rb +115 -0
  50. data/lib/vanken/ui/dialogs.rb +41 -26
  51. data/lib/vanken/ui/file_operations.rb +17 -12
  52. data/lib/vanken/ui/filter_operations.rb +43 -15
  53. data/lib/vanken/ui/main_view.rb +74 -29
  54. data/lib/vanken/ui/navigation_operations.rb +86 -0
  55. data/lib/vanken/ui/packet_source.rb +33 -1
  56. data/lib/vanken/ui/selection.rb +8 -2
  57. data/lib/vanken/ui/settings_operations.rb +233 -0
  58. data/lib/vanken/version.rb +1 -1
  59. data/packaging/README.md +41 -1
  60. data/packaging/linux/org.vanken.capture.policy +14 -0
  61. data/packaging/linux/vanken-capture.wrapper +6 -0
  62. data/packaging/linux/vanken.desktop +9 -0
  63. data/packaging/linux/vanken.sudoers +2 -0
  64. data/packaging/macos/chmod-bpf +6 -0
  65. data/packaging/macos/org.vanken.chmod-bpf.plist +8 -0
  66. data/sig/generated/vanken/app/capture_controller.rbs +16 -0
  67. data/sig/generated/vanken/app/document.rbs +23 -1
  68. data/sig/generated/vanken/app/document_analysis.rbs +21 -0
  69. data/sig/generated/vanken/app/document_jobs.rbs +6 -0
  70. data/sig/generated/vanken/app/expert_info.rbs +30 -0
  71. data/sig/generated/vanken/app/io_graph.rbs +45 -0
  72. data/sig/generated/vanken/app/navigation.rbs +31 -0
  73. data/sig/generated/vanken/app/search_job.rbs +21 -0
  74. data/sig/generated/vanken/app/stats_job.rbs +18 -0
  75. data/sig/generated/vanken/capture/helper_options.rbs +6 -0
  76. data/sig/generated/vanken/capture/permission_setup.rbs +43 -0
  77. data/sig/generated/vanken/config/analysis_settings.rbs +31 -0
  78. data/sig/generated/vanken/config/columns.rbs +37 -0
  79. data/sig/generated/vanken/config/messages.rbs +14 -0
  80. data/sig/generated/vanken/config/preferences.rbs +1 -1
  81. data/sig/generated/vanken/config/profiles.rbs +29 -0
  82. data/sig/generated/vanken/config/sessions.rbs +17 -0
  83. data/sig/generated/vanken/config/yaml_file.rbs +11 -0
  84. data/sig/generated/vanken/core/coloring.rbs +52 -0
  85. data/sig/generated/vanken/core/stores.rbs +4 -0
  86. data/sig/generated/vanken/gateway/display_capture_filter.rbs +37 -0
  87. data/sig/generated/vanken/gateway/dissector.rbs +15 -2
  88. data/sig/generated/vanken/gateway/exporter.rbs +27 -0
  89. data/sig/generated/vanken/gateway/file_writer.rbs +3 -0
  90. data/sig/generated/vanken/gateway/interfaces.rbs +12 -0
  91. data/sig/generated/vanken/gateway/resolver.rbs +52 -0
  92. data/sig/generated/vanken/gateway/statistics.rbs +104 -0
  93. data/sig/generated/vanken/gateway/stream.rbs +75 -0
  94. data/sig/generated/vanken/ui/analysis_dialogs.rbs +71 -0
  95. data/sig/generated/vanken/ui/application.rbs +23 -1
  96. data/sig/generated/vanken/ui/coloring_operations.rbs +29 -0
  97. data/sig/generated/vanken/ui/column_operations.rbs +29 -0
  98. data/sig/generated/vanken/ui/dialogs.rbs +5 -3
  99. data/sig/generated/vanken/ui/file_operations.rbs +2 -0
  100. data/sig/generated/vanken/ui/filter_operations.rbs +3 -1
  101. data/sig/generated/vanken/ui/main_view.rbs +14 -2
  102. data/sig/generated/vanken/ui/navigation_operations.rbs +43 -0
  103. data/sig/generated/vanken/ui/packet_source.rbs +4 -0
  104. data/sig/generated/vanken/ui/settings_operations.rbs +41 -0
  105. metadata +61 -3
@@ -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: Config::Preferences.new, debug: false)
27
- @preferences = preferences
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
- @filter_field = Zaniah::UI::TextField.new("", placeholder: "表示フィルタ", clearable: false).test_id("vk.filter.input")
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 = @app.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&.font
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
- "#{doc.loading? ? '読み込み中' : 'パケット'} #{@packet_source.packet_count} / #{total} 表示 #{@packet_source.count} ドロップ #{dropped}#{doc.progress ? " #{(doc.progress * 100).round}%" : ''}"
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
- @dialog = Zaniah::UI::Dialog.new(Zaniah::UI::Label.new(error.message, wrap: :word), title: "操作を完了できません").test_id("vk.error")
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
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../core/coloring"
4
+
5
+ module Vanken
6
+ module UI
7
+ module ColoringOperations
8
+ def coloring_rules = @coloring_rules ||= load_coloring_rules
9
+ def reload_coloring_rules
10
+ @coloring_rules = load_coloring_rules
11
+ @packet_source.reset
12
+ @window.request_frame
13
+ end
14
+ def replace_coloring_rules(payload)
15
+ rules = Core::Coloring::RuleSet.new(payload, catalog: document&.catalog)
16
+ rules.save(File.join(@preferences.directory, "coloring_rules.yml"))
17
+ @coloring_rules = rules
18
+ @packet_source.reset
19
+ @window.request_frame
20
+ rules
21
+ end
22
+ def coloring_enabled? = @preferences.get("packet_list.coloring")
23
+ def toggle_coloring
24
+ @preferences.set("packet_list.coloring", !coloring_enabled?)
25
+ @packet_source.reset
26
+ @window.request_frame
27
+ end
28
+
29
+ def coloring_dialog
30
+ @coloring_draft ||= coloring_rules.to_h
31
+ rules = @coloring_draft.fetch("rules")
32
+ @coloring_index = (@coloring_index || 0).clamp(0, [rules.size - 1, 0].max)
33
+ content = Zaniah::Div.new.flex_col.gap(12)
34
+ unless rules.empty?
35
+ content.child(Zaniah::UI::Select.new(rules.each_with_index.map { |rule, index| ["#{index + 1}. #{rule.fetch('name')}", index] }, label: t("ルール"), value: @coloring_index)
36
+ .on_change { |value, *_| @coloring_index = value; coloring_dialog }.test_id("vk.coloring.rule"))
37
+ rule = rules[@coloring_index]
38
+ content.child(Zaniah::UI::TextField.new(rule.fetch("name"), label: t("名前")).on_change { |value, *_| rule["name"] = value }.test_id("vk.coloring.name"))
39
+ content.child(Zaniah::UI::TextField.new(rule.fetch("filter"), label: t("表示フィルタ")).on_change { |value, *_| rule["filter"] = value }.test_id("vk.coloring.filter"))
40
+ content.child(Zaniah::UI::Checkbox.new(t("有効"), value: rule.fetch("enabled")).on_change { |value, *_| rule["enabled"] = value })
41
+ %w[light dark].each do |theme|
42
+ content.child(Zaniah::Div.new.flex_row.gap(12).children(%w[fg bg].map do |key|
43
+ label = t("%{theme} %{part}", theme: t(theme == "light" ? "ライト" : "ダーク"), part: t(key == "fg" ? "文字" : "背景"))
44
+ Zaniah::UI::ColorPicker.new(rule.fetch(theme).fetch(key), label: label).on_change { |value, *_| rule.fetch(theme)[key] = value }.flex_1
45
+ end))
46
+ end
47
+ content.child(Zaniah::Div.new.flex_row.gap(8)
48
+ .child(Zaniah::UI::Button.new(t("上へ移動"), variant: :secondary).disabled(@coloring_index.zero?).on_click { move_coloring_rule(-1) })
49
+ .child(Zaniah::UI::Button.new(t("下へ移動"), variant: :secondary).disabled(@coloring_index == rules.size - 1).on_click { move_coloring_rule(1) })
50
+ .child(Zaniah::UI::Button.new(t("削除"), variant: :secondary).on_click { rules.delete_at(@coloring_index); coloring_dialog }))
51
+ end
52
+ content.child(Zaniah::Div.new.flex_row.gap(8)
53
+ .child(Zaniah::UI::Button.new(t("追加"), variant: :secondary).on_click do
54
+ rules << {"name" => t("新しいルール"), "filter" => "tcp", "enabled" => true, "light" => {"fg" => "#12272E", "bg" => "#E7E6FF"}, "dark" => {"fg" => "#E7E6FF", "bg" => "#24233A"}}
55
+ @coloring_index = rules.size - 1
56
+ coloring_dialog
57
+ end)
58
+ .child(Zaniah::UI::Button.new(t("インポート"), variant: :secondary).on_click { import_coloring_rules })
59
+ .child(Zaniah::UI::Button.new(t("エクスポート"), variant: :secondary).on_click { export_coloring_rules }))
60
+ content.child(Zaniah::Div.new.flex_row.gap(8)
61
+ .child(Zaniah::UI::Button.new(t("適用")).test_id("vk.coloring.apply").on_click do
62
+ replace_coloring_rules(@coloring_draft)
63
+ @coloring_draft = nil
64
+ dismiss_dialog
65
+ rescue StandardError => error
66
+ show_error(error)
67
+ end)
68
+ .child(Zaniah::UI::Button.new(t("キャンセル"), variant: :secondary).on_click { @coloring_draft = nil; dismiss_dialog }))
69
+ viewport = Zaniah::ScrollView.new.h([(@window.content_size.height * 0.9) - 100, 120].max).child(content)
70
+ show_dialog(:coloring, t("色付けルール"), viewport, reopen: -> { coloring_dialog })
71
+ end
72
+
73
+ def import_coloring_rules
74
+ path_dialog(t("色付けルールの読み込み先")) do |path|
75
+ @coloring_draft = Core::Coloring::RuleSet.load(path, catalog: document&.catalog).to_h
76
+ @coloring_index = 0
77
+ coloring_dialog
78
+ end
79
+ end
80
+ def export_coloring_rules
81
+ path_dialog(t("色付けルールの保存先")) do |path|
82
+ Core::Coloring::RuleSet.new(@coloring_draft || coloring_rules.to_h, catalog: document&.catalog).save(path)
83
+ coloring_dialog
84
+ end
85
+ end
86
+
87
+ private
88
+ def load_coloring_rules
89
+ path = File.join(@preferences.directory, "coloring_rules.yml")
90
+ File.file?(path) ? Core::Coloring::RuleSet.load(path, catalog: document&.catalog) : Core::Coloring::RuleSet.defaults(catalog: document&.catalog)
91
+ rescue Vanken::ConfigError => error
92
+ show_error(error)
93
+ Core::Coloring::RuleSet.defaults(catalog: document&.catalog)
94
+ end
95
+ def move_coloring_rule(delta)
96
+ rules = @coloring_draft.fetch("rules")
97
+ target = @coloring_index + delta
98
+ rules[@coloring_index], rules[target] = rules[target], rules[@coloring_index]
99
+ @coloring_index = target
100
+ coloring_dialog
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../config/columns"
4
+
5
+ module Vanken
6
+ module UI
7
+ module ColumnOperations
8
+ def column_settings
9
+ if !@column_settings || @columns_directory != @preferences.directory
10
+ @columns_directory = @preferences.directory
11
+ @column_settings = Config::Columns.load(directory: @columns_directory, legacy: @preferences.get("layout.columns"))
12
+ end
13
+ @column_settings
14
+ rescue ConfigError => error
15
+ show_error(error)
16
+ @column_settings = Config::Columns.new
17
+ end
18
+
19
+ def reload_columns
20
+ @column_settings = nil
21
+ column_settings
22
+ configure_document_columns
23
+ end
24
+
25
+ def configure_document_columns
26
+ document.custom_columns = column_settings.custom if document
27
+ @packet_source.reset
28
+ end
29
+
30
+ def persist_columns_layout(items)
31
+ column_settings.update_layout(items)
32
+ column_settings.save(@preferences.directory)
33
+ configure_document_columns
34
+ rescue StandardError => error
35
+ show_error(error)
36
+ end
37
+
38
+ def add_field_column(field, label: field, width: 120)
39
+ if document && !Core::DisplayFilter::FieldResolver.new(document.catalog).resolve(field).known
40
+ raise Vanken::ConfigError, "unknown field: #{field}"
41
+ end
42
+ key = column_settings.add(field, label: label, width: width)
43
+ apply_columns
44
+ key
45
+ end
46
+
47
+ def remove_column(key)
48
+ column_settings.remove(key)
49
+ apply_columns
50
+ end
51
+
52
+ def field_menu(node)
53
+ menu = selection_menu(node.filter)
54
+ return menu unless node.field
55
+ @app.actions.register(:context_apply_column, title: column_text("列として適用")) { add_field_column(node.field) }
56
+ added = Zaniah::Menu.build { item(:context_apply_column) }
57
+ Zaniah::Menu.new(menu.items + added.items)
58
+ end
59
+
60
+ def columns_dialog
61
+ rows = Zaniah::Div.new.flex_col.gap(6)
62
+ @table.columns.each_with_index do |column, index|
63
+ key = column[:key]
64
+ width = Zaniah::UI::NumberInput.new(column[:width], min: 40, max: 4096, step: 8, label: column_text("幅"))
65
+ .w(100).test_id("vk.columns.width.#{key}")
66
+ width.on_change do |*_|
67
+ amount = width.number
68
+ if amount && amount.finite? && amount.between?(40, 4096)
69
+ width.status(:none)
70
+ @table.resize_column(key, amount)
71
+ else
72
+ width.status(:error, message: column_text("幅は40から4096の範囲で指定してください"))
73
+ end
74
+ end
75
+ row = Zaniah::Div.new.flex_row.items_center.gap(6)
76
+ .child(Zaniah::UI::Checkbox.new(column[:label], value: column[:visible]).flex_1
77
+ .on_change { |value, *_| @table.column_visible(key, value) })
78
+ .child(width)
79
+ .child(Zaniah::UI::Button.new(column_text("上へ移動"), size: :sm).disabled(index.zero?).on_click { @table.move_column(key, index - 1); columns_dialog })
80
+ .child(Zaniah::UI::Button.new(column_text("下へ移動"), size: :sm).disabled(index == @table.columns.size - 1).on_click { @table.move_column(key, index + 1); columns_dialog })
81
+ row.child(Zaniah::UI::Button.new(column_text("削除"), size: :sm).on_click { remove_column(key); columns_dialog }) if key.to_s.start_with?("field:")
82
+ rows.child(row)
83
+ end
84
+ field = Zaniah::UI::TextField.new("", label: column_text("フィールド名")).test_id("vk.columns.field")
85
+ label = Zaniah::UI::TextField.new("", label: column_text("列名 (空欄: フィールド名)")).test_id("vk.columns.label")
86
+ content = Zaniah::Div.new.flex_col.gap(8)
87
+ .child(Zaniah::ScrollView.new.h(280).child(rows)).child(field).child(label)
88
+ .child(Zaniah::UI::Button.new(column_text("追加")).test_id("vk.columns.add").on_click do
89
+ name = field.value.strip
90
+ add_field_column(name, label: label.value.empty? ? name : label.value)
91
+ columns_dialog
92
+ rescue StandardError => error
93
+ show_error(error)
94
+ end)
95
+ show_dialog(:columns, column_text("表示する列"), content, reopen: -> { columns_dialog })
96
+ end
97
+
98
+ private
99
+
100
+ def column_text(text) = respond_to?(:t) ? t(text) : text
101
+
102
+ def apply_columns
103
+ column_settings.save(@preferences.directory)
104
+ configure_document_columns
105
+ selected = selected_number
106
+ rebuild_view
107
+ index = document&.displayed_index(selected)
108
+ if index
109
+ @table.select(index)
110
+ @table.scroll_to(index, align: :nearest)
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
@@ -5,18 +5,23 @@ module Vanken
5
5
  module Dialogs
6
6
  def dialog = @dialog
7
7
  def dismiss_dialog
8
+ close_analysis if respond_to?(:close_analysis)
8
9
  @dialog&.close
9
- @dialog = @dialog_kind = @pending_destructive = nil
10
+ @dialog = @dialog_kind = @pending_destructive = @dialog_reopen = nil
10
11
  @window.request_frame
11
12
  end
12
- def show_dialog(kind, title, content)
13
+ def reopen_dialog
14
+ @dialog_reopen&.call
15
+ end
16
+ def show_dialog(kind, title, content, reopen: nil)
13
17
  dismiss_dialog
14
18
  @dialog_kind = kind
15
- @dialog = Zaniah::UI::Dialog.new(content, title: title).on_close { dismiss_dialog }.test_id("vk.#{kind}")
19
+ @dialog_reopen = reopen
20
+ @dialog = Zaniah::UI::Dialog.new(content, title: title, close_label: t("閉じる")).on_close { dismiss_dialog }.test_id("vk.#{kind}")
16
21
  @window.request_frame
17
22
  end
18
- def path_dialog(title, &operation)
19
- field = Zaniah::UI::TextField.new("", label: title).test_id("vk.path.input")
23
+ def path_dialog(title, value: "", &operation)
24
+ field = Zaniah::UI::TextField.new(value, label: t(title)).test_id("vk.path.input")
20
25
  content = Zaniah::Div.new.flex_col.gap(12).child(field)
21
26
  .child(Zaniah::Div.new.flex_row.gap(8)
22
27
  .child(Zaniah::UI::Button.new("OK").on_click do
@@ -25,32 +30,42 @@ module Vanken
25
30
  rescue StandardError => error
26
31
  show_error(error)
27
32
  end)
28
- .child(Zaniah::UI::Button.new("キャンセル", variant: :secondary).on_click { dismiss_dialog }))
29
- show_dialog(:path, title, content)
33
+ .child(Zaniah::UI::Button.new(t("キャンセル"), variant: :secondary).on_click { dismiss_dialog }))
34
+ show_dialog(:path, t(title), content, reopen: -> { path_dialog(title, value: field.value, &operation) })
30
35
  end
31
- def capture_options(interface: nil)
36
+ def capture_options(interface: nil, values: {})
32
37
  fields = {}
33
38
  infos = @interface_infos || []
34
39
  choices = infos.map { |item| ["#{item.fetch(:name)} #{item[:description]}", item.fetch(:name)] }
35
40
  selected = choices.any? { |choice| choice.last == interface } ? interface : choices.first&.last
36
- selector = Zaniah::UI::Select.new(choices, label: "インタフェース", value: selected)
41
+ selector = Zaniah::UI::Select.new(choices, label: t("インタフェース"), value: selected)
37
42
  selector.on_change { |value, *_| selected = value }
38
43
  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)
44
+ [["filter", t("キャプチャフィルタ (BPF)"), ""], ["snaplen", "Snaplen", @preferences.get("capture.snaplen").to_s],
45
+ ["buffer_size", t("バッファサイズ (bytes)"), @preferences.get("capture.buffer_size").to_s]].each do |key, label, value|
46
+ fields[key] = Zaniah::UI::TextField.new(values.fetch(key, value), label: label)
42
47
  content.child(fields[key])
43
48
  end
44
- promiscuous = Zaniah::UI::Checkbox.new("プロミスキャスモード", value: @preferences.get("capture.promiscuous"))
49
+ advanced = Zaniah::Div.new.flex_col.gap(6)
50
+ {"stop_count" => t("停止件数 (0: 無制限)"), "stop_duration" => t("停止までの秒数 (0: 無制限)"), "stop_bytes" => t("停止サイズ (bytes、0: 無制限)"),
51
+ "ring_path" => t("リング保存先 (空欄: 保存しない)"), "ring_max_bytes" => t("リングファイル上限 (bytes)"), "ring_interval" => t("リング切替間隔 (秒)"), "ring_file_count" => t("リングファイル数")}.each do |key, label|
52
+ fields[key] = Zaniah::UI::TextField.new(values.fetch(key, @preferences.get("capture.#{key}").to_s), label: label).test_id("vk.capture.#{key}")
53
+ advanced.child(fields[key])
54
+ end
55
+ content.child(Zaniah::ScrollView.new.h(160).child(advanced))
56
+ promiscuous = Zaniah::UI::Checkbox.new(t("プロミスキャスモード"), value: values.fetch("promiscuous", @preferences.get("capture.promiscuous")))
45
57
  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)
58
+ direction = values.fetch("direction", @preferences.get("capture.direction"))
59
+ content.child(Zaniah::UI::Select.new([[t("送受信"), "inout"], [t("受信"), "in"], [t("送信"), "out"]], label: t("方向"), value: direction).on_change { |value, *_| direction = value })
60
+ status = Zaniah::UI::Label.new(infos.empty? ? (@interface_error || t("インタフェースを取得中です。")) : t("GUI は通常ユーザーで動作し、取得ヘルパーに必要な権限を渡します。"), wrap: :word, tone: :muted)
49
61
  content.child(status)
50
- content.child(Zaniah::UI::Button.new("開始").test_id("vk.capture.start").disabled(infos.empty?).on_click do
62
+ content.child(Zaniah::UI::Button.new(t("開始")).test_id("vk.capture.start").disabled(infos.empty?).on_click do
51
63
  options = {"interface" => selected, "snaplen" => Integer(fields.fetch("snaplen").value, 10),
52
64
  "buffer_size" => Integer(fields.fetch("buffer_size").value, 10), "filter" => fields.fetch("filter").value,
53
65
  "promiscuous" => promiscuous.value, "direction" => direction, "backend" => @preferences.get("capture.backend")}
66
+ %w[stop_count stop_bytes ring_max_bytes ring_file_count].each { |key| options[key] = Integer(fields.fetch(key).value, 10) }
67
+ %w[stop_duration ring_interval].each { |key| options[key] = Float(fields.fetch(key).value) }
68
+ options["ring_path"] = fields.fetch("ring_path").value
54
69
  # Compilation and privileged helper startup both happen away from the UI thread.
55
70
  @app.executor.background do
56
71
  metadata = infos.find { |item| item[:name] == selected }
@@ -62,27 +77,27 @@ module Vanken
62
77
  rescue ArgumentError => error
63
78
  show_error(error)
64
79
  end)
65
- show_dialog(:capture_options, "キャプチャオプション", content)
80
+ show_dialog(:capture_options, t("キャプチャオプション"), content, reopen: -> { capture_options(interface: selected, values: fields.transform_values(&:value).merge("promiscuous" => promiscuous.value, "direction" => direction)) })
66
81
  end
67
82
  def preferences_dialog
68
- theme = Zaniah::UI::Select.new(%w[system dark light high_contrast], label: "テーマ", value: @preferences.get("appearance.theme"))
83
+ theme = Zaniah::UI::Select.new({"システム設定" => "system", "ダーク" => "dark", "ライト" => "light", "ハイコントラスト" => "high_contrast"}.map { |label, value| [t(label), value] }, label: t("テーマ"), value: @preferences.get("appearance.theme"))
69
84
  .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"))
85
+ time = Zaniah::UI::Select.new({"相対時刻" => "relative", "絶対時刻" => "absolute", "前パケットとの差" => "delta", "前表示パケットとの差" => "delta_displayed", "エポック時刻" => "epoch"}.map { |label, value| [t(label), value] }, label: t("時刻表示"), value: @preferences.get("packet_list.time_format"))
71
86
  .on_change { |value, *_| time_format(value) }
72
87
  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)
88
+ .child(Zaniah::Div.new.flex_row.gap(8).child(Zaniah::UI::Button.new(t("文字を小さく")).on_click { zoom(-1) })
89
+ .child(Zaniah::UI::Button.new(t("文字を大きく")).on_click { zoom(1) }))
90
+ show_dialog(:preferences, t("設定"), content, reopen: -> { preferences_dialog })
76
91
  end
77
92
  def columns_dialog
78
93
  content = Zaniah::Div.new.flex_col.gap(8)
79
94
  @table.columns.each_with_index do |column, index|
80
95
  content.child(Zaniah::Div.new.flex_row.gap(8)
81
96
  .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 }))
97
+ .child(Zaniah::UI::Button.new(t("上へ移動"), size: :sm).disabled(index.zero?).on_click { @table.move_column(column[:key], index - 1); columns_dialog })
98
+ .child(Zaniah::UI::Button.new(t("下へ移動"), size: :sm).disabled(index == @table.columns.size - 1).on_click { @table.move_column(column[:key], index + 1); columns_dialog }))
84
99
  end
85
- show_dialog(:columns, "表示する列", content)
100
+ show_dialog(:columns, t("表示する列"), content, reopen: -> { columns_dialog })
86
101
  end
87
102
  end
88
103
  end