openclacky 1.5.13 → 1.5.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +96 -0
- data/lib/clacky/agent/chunk_index.rb +83 -0
- data/lib/clacky/agent/history_navigation.rb +239 -0
- data/lib/clacky/agent/session_serializer.rb +53 -98
- data/lib/clacky/agent.rb +477 -297
- data/lib/clacky/agent_config.rb +5 -3
- data/lib/clacky/billing/billing_store.rb +2 -2
- data/lib/clacky/billing/platform_billing.rb +7 -0
- data/lib/clacky/brand_config.rb +27 -19
- data/lib/clacky/cli.rb +92 -12
- data/lib/clacky/cli_guidance.rb +48 -0
- data/lib/clacky/client.rb +2 -2
- data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/system_prompt.md +70 -134
- data/lib/clacky/default_extensions/ext-studio/api/handler.rb +4 -1
- data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +307 -98
- data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +179 -577
- data/lib/clacky/default_extensions/git/panels/git/view.js +34 -14
- data/lib/clacky/default_extensions/preview/ext.yml +20 -0
- data/lib/clacky/default_extensions/preview/panels/preview/view.js +475 -0
- data/lib/clacky/default_extensions/time_machine/panels/time_machine/view.js +1 -10
- data/lib/clacky/extension/api_extension.rb +18 -6
- data/lib/clacky/extension/verifier.rb +1 -1
- data/lib/clacky/json_ui_controller.rb +4 -2
- data/lib/clacky/media/openai_compat.rb +70 -30
- data/lib/clacky/message_format/bedrock.rb +6 -1
- data/lib/clacky/message_format/open_ai.rb +22 -5
- data/lib/clacky/message_format/open_ai_responses.rb +7 -3
- data/lib/clacky/plain_ui_controller.rb +1 -1
- data/lib/clacky/prompts/base.md +1 -1
- data/lib/clacky/providers.rb +44 -13
- data/lib/clacky/rich_ui/components/composer_guidance.rb +49 -0
- data/lib/clacky/rich_ui/rich_ui_controller.rb +24 -4
- data/lib/clacky/rich_ui/shell/rich_agent_shell.rb +13 -0
- data/lib/clacky/search_config.rb +3 -3
- data/lib/clacky/server/channel/channel_manager.rb +25 -7
- data/lib/clacky/server/channel/channel_ui_controller.rb +1 -1
- data/lib/clacky/server/dir_picker.rb +67 -4
- data/lib/clacky/server/git_panel.rb +10 -2
- data/lib/clacky/server/http_server.rb +391 -75
- data/lib/clacky/server/preview.rb +351 -0
- data/lib/clacky/server/session_registry.rb +4 -0
- data/lib/clacky/server/web_ui_controller.rb +10 -5
- data/lib/clacky/session_manager.rb +52 -1
- data/lib/clacky/skill.rb +48 -0
- data/lib/clacky/tools/browser.rb +176 -19
- data/lib/clacky/tools/web_search.rb +91 -8
- data/lib/clacky/ui2/components/command_suggestions.rb +3 -1
- data/lib/clacky/ui2/components/input_area.rb +37 -4
- data/lib/clacky/ui2/components/modal_component.rb +35 -6
- data/lib/clacky/ui2/screen_buffer.rb +1 -0
- data/lib/clacky/ui2/ui_controller.rb +72 -12
- data/lib/clacky/ui_interface.rb +1 -1
- data/lib/clacky/utils/file_processor.rb +19 -7
- data/lib/clacky/utils/mac_app_detector.rb +186 -0
- data/lib/clacky/utils/model_pricing.rb +118 -68
- data/lib/clacky/utils/windows_app_detector.rb +334 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +998 -168
- data/lib/clacky/web/app.js +19 -0
- data/lib/clacky/web/components/chat-navigator.js +489 -202
- data/lib/clacky/web/components/code-editor.js +191 -5
- data/lib/clacky/web/components/composer.js +3 -1
- data/lib/clacky/web/components/datepicker.js +19 -19
- data/lib/clacky/web/components/mentions.js +11 -14
- data/lib/clacky/web/components/model-picker.js +66 -23
- data/lib/clacky/web/components/quote-select.js +341 -0
- data/lib/clacky/web/core/aside.js +157 -8
- data/lib/clacky/web/core/ext.js +16 -0
- data/lib/clacky/web/features/billing/view.js +75 -21
- data/lib/clacky/web/features/extensions/store.js +4 -1
- data/lib/clacky/web/features/model-tester/store.js +37 -4
- data/lib/clacky/web/features/skills/store.js +18 -1
- data/lib/clacky/web/features/skills/view.js +128 -7
- data/lib/clacky/web/features/workspace/store.js +80 -7
- data/lib/clacky/web/features/workspace/view.js +474 -43
- data/lib/clacky/web/i18n.js +158 -23
- data/lib/clacky/web/index.html +57 -29
- data/lib/clacky/web/sessions.js +517 -127
- data/lib/clacky/web/settings.js +155 -6
- data/lib/clacky/web/utils.js +34 -0
- data/lib/clacky/web/vendor/codemirror/codemirror.min.js +24 -19
- data/lib/clacky/web/vendor/codemirror/entry.js +130 -0
- data/lib/clacky/web/vendor/codemirror/package.json +29 -0
- data/lib/clacky/web/ws-dispatcher.js +161 -4
- data/lib/clacky.rb +2 -0
- metadata +13 -1
|
@@ -10,12 +10,14 @@ require_relative "components/welcome_banner"
|
|
|
10
10
|
require_relative "components/inline_input"
|
|
11
11
|
require_relative "thinking_verbs"
|
|
12
12
|
require_relative "../ui_interface"
|
|
13
|
+
require_relative "../cli_guidance"
|
|
13
14
|
|
|
14
15
|
module Clacky
|
|
15
16
|
module UI2
|
|
16
17
|
# UIController is the MVC controller layer that coordinates UI state and user interactions
|
|
17
18
|
class UIController
|
|
18
19
|
include Clacky::UIInterface
|
|
20
|
+
include Clacky::CliGuidance
|
|
19
21
|
|
|
20
22
|
attr_reader :layout, :renderer, :running, :inline_input, :input_area
|
|
21
23
|
attr_accessor :config, :available_models
|
|
@@ -31,6 +33,7 @@ module Clacky
|
|
|
31
33
|
working_dir: config[:working_dir],
|
|
32
34
|
mode: config[:mode],
|
|
33
35
|
model: config[:model],
|
|
36
|
+
reasoning_effort: config[:reasoning_effort],
|
|
34
37
|
theme: config[:theme]
|
|
35
38
|
}
|
|
36
39
|
|
|
@@ -81,6 +84,7 @@ module Clacky
|
|
|
81
84
|
working_dir: @config[:working_dir],
|
|
82
85
|
mode: @config[:mode],
|
|
83
86
|
model: @config[:model],
|
|
87
|
+
reasoning_effort: @config[:reasoning_effort],
|
|
84
88
|
tasks: @tasks_count,
|
|
85
89
|
cost: @total_cost
|
|
86
90
|
)
|
|
@@ -124,6 +128,7 @@ module Clacky
|
|
|
124
128
|
working_dir: @config[:working_dir],
|
|
125
129
|
mode: @config[:mode],
|
|
126
130
|
model: @config[:model],
|
|
131
|
+
reasoning_effort: @config[:reasoning_effort],
|
|
127
132
|
tasks: @tasks_count,
|
|
128
133
|
cost: @total_cost,
|
|
129
134
|
cost_source: cost_source,
|
|
@@ -132,6 +137,16 @@ module Clacky
|
|
|
132
137
|
@layout.render_input
|
|
133
138
|
end
|
|
134
139
|
|
|
140
|
+
def refresh_guidance
|
|
141
|
+
return unless @running
|
|
142
|
+
@layout.recalculate_layout
|
|
143
|
+
@layout.render_input
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def show_user_message(content, created_at: nil, files: [], source: :web, steering: false)
|
|
147
|
+
append_output(@renderer.render_user_message(content, files: files)) if steering
|
|
148
|
+
end
|
|
149
|
+
|
|
135
150
|
def show_goal_status(goal)
|
|
136
151
|
@input_area.set_goal(goal)
|
|
137
152
|
@layout.render_input
|
|
@@ -183,6 +198,8 @@ module Clacky
|
|
|
183
198
|
|
|
184
199
|
# Set callback for user input
|
|
185
200
|
# @param block [Proc] Callback to execute with user input
|
|
201
|
+
attr_accessor :queue_input_while_running
|
|
202
|
+
|
|
186
203
|
def on_input(&block)
|
|
187
204
|
@input_callback = block
|
|
188
205
|
end
|
|
@@ -582,7 +599,7 @@ module Clacky
|
|
|
582
599
|
|
|
583
600
|
# Show tool result
|
|
584
601
|
# @param result [String] Formatted tool result
|
|
585
|
-
def show_tool_result(result)
|
|
602
|
+
def show_tool_result(result, ui: nil)
|
|
586
603
|
output = @renderer.render_tool_result(result: result)
|
|
587
604
|
append_output(output)
|
|
588
605
|
end
|
|
@@ -1271,6 +1288,8 @@ module Clacky
|
|
|
1271
1288
|
"",
|
|
1272
1289
|
theme.format_text("Commands:", :info),
|
|
1273
1290
|
" #{theme.format_text("/model", :success)} - Quickly switch the current model",
|
|
1291
|
+
" #{theme.format_text("/input-mode", :success)} - View or set steer / interrupt behavior",
|
|
1292
|
+
" #{theme.format_text("/think", :success)} - Set the thinking (reasoning) effort level",
|
|
1274
1293
|
" #{theme.format_text("/config", :success)} - Configure models, API keys, settings",
|
|
1275
1294
|
" #{theme.format_text("/goal", :success)} - Set a standing goal for autonomous work",
|
|
1276
1295
|
" #{theme.format_text("/goal <text>", :dim)} Set a goal and start working toward it",
|
|
@@ -1704,6 +1723,11 @@ module Clacky
|
|
|
1704
1723
|
intervene_feedback_countdown
|
|
1705
1724
|
end
|
|
1706
1725
|
|
|
1726
|
+
if !@feedback_countdown && [:ctrl_d, :ctrl_g].include?(key)
|
|
1727
|
+
action = key == :ctrl_d ? :remove : :send_now
|
|
1728
|
+
return if request_guidance_action(action)
|
|
1729
|
+
end
|
|
1730
|
+
|
|
1707
1731
|
result = @input_area.handle_key(key)
|
|
1708
1732
|
|
|
1709
1733
|
# Handle height change first
|
|
@@ -1803,6 +1827,7 @@ module Clacky
|
|
|
1803
1827
|
working_dir: @config[:working_dir],
|
|
1804
1828
|
mode: @config[:mode],
|
|
1805
1829
|
model: @config[:model],
|
|
1830
|
+
reasoning_effort: @config[:reasoning_effort],
|
|
1806
1831
|
tasks: @tasks_count,
|
|
1807
1832
|
cost: @total_cost
|
|
1808
1833
|
)
|
|
@@ -1816,15 +1841,18 @@ module Clacky
|
|
|
1816
1841
|
# so no ticker thread is left writing into the buffer while we render
|
|
1817
1842
|
# the new user message. Each handle's finish() renders its own final
|
|
1818
1843
|
# frame with elapsed time, so the user still sees a summary.
|
|
1819
|
-
|
|
1844
|
+
queuing = guidance_deferred?(data[:text])
|
|
1845
|
+
interrupt_all_progress if progress_active? && !queuing
|
|
1820
1846
|
|
|
1821
1847
|
# Also clear stdout buffer used by Ctrl+O (unrelated to progress, but
|
|
1822
1848
|
# we don't want stale command output carried across user turns).
|
|
1823
|
-
|
|
1824
|
-
|
|
1849
|
+
unless queuing
|
|
1850
|
+
@stdout_lines = nil
|
|
1851
|
+
@stdout_partial_tail = false
|
|
1852
|
+
end
|
|
1825
1853
|
|
|
1826
1854
|
# Render user message immediately before running agent
|
|
1827
|
-
unless data[:text].empty? && data[:files].empty?
|
|
1855
|
+
unless queuing || (data[:text].empty? && data[:files].empty?)
|
|
1828
1856
|
output = @renderer.render_user_message(data[:text], files: data[:files])
|
|
1829
1857
|
append_output(output)
|
|
1830
1858
|
end
|
|
@@ -1871,14 +1899,15 @@ module Clacky
|
|
|
1871
1899
|
}
|
|
1872
1900
|
end
|
|
1873
1901
|
|
|
1874
|
-
# Add action buttons
|
|
1875
|
-
|
|
1876
|
-
choices << { name: "
|
|
1902
|
+
# Add action buttons (sticky: always visible below the scrollable
|
|
1903
|
+
# model list, so a long model list can't push them off screen)
|
|
1904
|
+
choices << { name: "─" * 50, disabled: true, sticky: true }
|
|
1905
|
+
choices << { name: "[+] Add New Model", value: { action: :add }, sticky: true }
|
|
1877
1906
|
if current_config.models.length > 0
|
|
1878
|
-
choices << { name: "[*] Edit Current Model", value: { action: :edit } }
|
|
1879
|
-
choices << { name: "[-] Delete Model", value: { action: :delete } } if current_config.models.length > 1
|
|
1907
|
+
choices << { name: "[*] Edit Current Model", value: { action: :edit }, sticky: true }
|
|
1908
|
+
choices << { name: "[-] Delete Model", value: { action: :delete }, sticky: true } if current_config.models.length > 1
|
|
1880
1909
|
end
|
|
1881
|
-
choices << { name: "[X] Close", value: { action: :close } }
|
|
1910
|
+
choices << { name: "[X] Close", value: { action: :close }, sticky: true }
|
|
1882
1911
|
|
|
1883
1912
|
# Show menu
|
|
1884
1913
|
result = modal.show(
|
|
@@ -1903,7 +1932,7 @@ module Clacky
|
|
|
1903
1932
|
if new_model
|
|
1904
1933
|
# Determine anthropic_format based on provider
|
|
1905
1934
|
# For Anthropic provider, use Anthropic API format
|
|
1906
|
-
anthropic_format = new_model[:provider] ==
|
|
1935
|
+
anthropic_format = new_model[:provider] == Clacky::Providers::ANTHROPIC_ID
|
|
1907
1936
|
|
|
1908
1937
|
current_config.add_model(
|
|
1909
1938
|
model: new_model[:model],
|
|
@@ -2121,6 +2150,37 @@ module Clacky
|
|
|
2121
2150
|
result # Return selected task_id or nil
|
|
2122
2151
|
end
|
|
2123
2152
|
|
|
2153
|
+
# Show a reasoning-effort picker for the `/think` command.
|
|
2154
|
+
# @param current_effort [String, nil] the agent's current effort level
|
|
2155
|
+
# @return [String, nil] the chosen level ("off"/"low"/.../"max") or nil if cancelled
|
|
2156
|
+
public def show_reasoning_effort_menu(current_effort)
|
|
2157
|
+
modal = Components::ModalComponent.new
|
|
2158
|
+
|
|
2159
|
+
descriptions = {
|
|
2160
|
+
"off" => "Provider default (no effort override)",
|
|
2161
|
+
"low" => "Light reasoning - fastest",
|
|
2162
|
+
"medium" => "Balanced reasoning",
|
|
2163
|
+
"high" => "Deep reasoning",
|
|
2164
|
+
"xhigh" => "Extra deep reasoning",
|
|
2165
|
+
"max" => "Maximum reasoning - slowest"
|
|
2166
|
+
}
|
|
2167
|
+
|
|
2168
|
+
active_index = nil
|
|
2169
|
+
choices = descriptions.keys.each_with_index.map do |level, idx|
|
|
2170
|
+
is_current = current_effort.nil? ? level == "off" : level == current_effort
|
|
2171
|
+
active_index = idx if is_current
|
|
2172
|
+
marker = is_current ? "● " : " "
|
|
2173
|
+
{ name: "#{marker}#{level} - #{descriptions[level]}", value: level }
|
|
2174
|
+
end
|
|
2175
|
+
|
|
2176
|
+
modal.show(
|
|
2177
|
+
title: "Thinking Level - Set Reasoning Effort",
|
|
2178
|
+
choices: choices,
|
|
2179
|
+
initial_index: active_index,
|
|
2180
|
+
on_close: -> { @layout.rerender_all }
|
|
2181
|
+
)
|
|
2182
|
+
end
|
|
2183
|
+
|
|
2124
2184
|
# Show form for editing a model
|
|
2125
2185
|
# @param model [Hash, nil] Existing model hash or nil for new model
|
|
2126
2186
|
# @return [Hash, nil] Updated model hash or nil if cancelled
|
data/lib/clacky/ui_interface.rb
CHANGED
|
@@ -14,7 +14,7 @@ module Clacky
|
|
|
14
14
|
def show_subagent_start(skill: nil, iterations: nil, cost_usd: nil); end
|
|
15
15
|
def show_subagent_end; end
|
|
16
16
|
def show_tool_call(name, args); end
|
|
17
|
-
def show_tool_result(result); end
|
|
17
|
+
def show_tool_result(result, ui: nil); end
|
|
18
18
|
def show_tool_stdout(lines); end
|
|
19
19
|
def show_tool_error(error); end
|
|
20
20
|
def show_tool_args(formatted_args); end
|
|
@@ -299,7 +299,9 @@ module Clacky
|
|
|
299
299
|
Zip::InputStream.open(StringIO.new(body)) do |zis|
|
|
300
300
|
while (entry = zis.get_next_entry)
|
|
301
301
|
size = entry.size ? " (#{entry.size} bytes)" : ""
|
|
302
|
-
|
|
302
|
+
# rubyzip returns names as raw ASCII-8BIT bytes regardless of the zip's
|
|
303
|
+
# UTF-8 flag; convert so the listing never carries binary encoding.
|
|
304
|
+
lines << "- #{Clacky::Utils::Encoding.to_utf8(entry.name)}#{size}"
|
|
303
305
|
end
|
|
304
306
|
end
|
|
305
307
|
lines.join("\n")
|
|
@@ -325,7 +327,7 @@ module Clacky
|
|
|
325
327
|
tar.each do |entry|
|
|
326
328
|
kind = entry.directory? ? "[dir] " : ""
|
|
327
329
|
size = entry.header.size ? " (#{entry.header.size} bytes)" : ""
|
|
328
|
-
lines << "- #{kind}#{entry.full_name}#{size}"
|
|
330
|
+
lines << "- #{kind}#{Clacky::Utils::Encoding.to_utf8(entry.full_name)}#{size}"
|
|
329
331
|
end
|
|
330
332
|
end
|
|
331
333
|
end
|
|
@@ -339,7 +341,7 @@ module Clacky
|
|
|
339
341
|
tar.each do |entry|
|
|
340
342
|
kind = entry.directory? ? "[dir] " : ""
|
|
341
343
|
size = entry.header.size ? " (#{entry.header.size} bytes)" : ""
|
|
342
|
-
lines << "- #{kind}#{entry.full_name}#{size}"
|
|
344
|
+
lines << "- #{kind}#{Clacky::Utils::Encoding.to_utf8(entry.full_name)}#{size}"
|
|
343
345
|
end
|
|
344
346
|
end
|
|
345
347
|
end
|
|
@@ -359,7 +361,7 @@ module Clacky
|
|
|
359
361
|
found_tar = true
|
|
360
362
|
kind = entry.directory? ? "[dir] " : ""
|
|
361
363
|
size = entry.header.size ? " (#{entry.header.size} bytes)" : ""
|
|
362
|
-
lines << "- #{kind}#{entry.full_name}#{size}"
|
|
364
|
+
lines << "- #{kind}#{Clacky::Utils::Encoding.to_utf8(entry.full_name)}#{size}"
|
|
363
365
|
end
|
|
364
366
|
end
|
|
365
367
|
end
|
|
@@ -388,7 +390,8 @@ module Clacky
|
|
|
388
390
|
end
|
|
389
391
|
end
|
|
390
392
|
lines = ["# GZIP Contents\n"]
|
|
391
|
-
|
|
393
|
+
display_name = original_name ? Clacky::Utils::Encoding.to_utf8(original_name) : "(not recorded)"
|
|
394
|
+
lines << "- Original filename: #{display_name}"
|
|
392
395
|
lines << "- Compressed size: #{File.size(path)} bytes"
|
|
393
396
|
lines << "- Uncompressed size: #{uncompressed} bytes#{uncompressed && uncompressed > 64 * 1024 * 1024 ? " (truncated)" : ""}"
|
|
394
397
|
lines.join("\n")
|
|
@@ -406,7 +409,14 @@ module Clacky
|
|
|
406
409
|
FileUtils.mkdir_p(UPLOAD_DIR)
|
|
407
410
|
safe_name = File.basename(original_path.to_s).gsub(/[\/\:\*?"<>|\x00]/, "_")
|
|
408
411
|
dest = File.join(UPLOAD_DIR, "#{SecureRandom.hex(8)}_#{safe_name}.preview.md")
|
|
409
|
-
File.write
|
|
412
|
+
# File.write transcodes to the default external encoding and raises
|
|
413
|
+
# "\xE8 from ASCII-8BIT to UTF-8" on binary (ASCII-8BIT) content, which
|
|
414
|
+
# can leak in from archive entry names / external parsers.
|
|
415
|
+
text = content.to_s
|
|
416
|
+
unless text.encoding == ::Encoding::UTF_8 && text.valid_encoding?
|
|
417
|
+
text = Clacky::Utils::Encoding.to_utf8(text)
|
|
418
|
+
end
|
|
419
|
+
File.write(dest, text)
|
|
410
420
|
dest
|
|
411
421
|
end
|
|
412
422
|
|
|
@@ -540,7 +550,9 @@ module Clacky
|
|
|
540
550
|
LOCAL_IMAGE_EXTENSIONS = %w[.png .jpg .jpeg .gif .webp].freeze
|
|
541
551
|
LOCAL_VIDEO_EXTENSIONS = %w[.mp4 .webm .mov].freeze
|
|
542
552
|
LOCAL_AUDIO_EXTENSIONS = %w[.wav .mp3 .ogg .aac .flac .m4a].freeze
|
|
543
|
-
|
|
553
|
+
# .pdf: served by /api/local-image so the WebUI file viewer can embed it
|
|
554
|
+
# in an iframe (browser-native PDF rendering, no extra dependency).
|
|
555
|
+
LOCAL_MEDIA_EXTENSIONS = (LOCAL_IMAGE_EXTENSIONS + LOCAL_VIDEO_EXTENSIONS + LOCAL_AUDIO_EXTENSIONS + %w[.pdf]).freeze
|
|
544
556
|
|
|
545
557
|
# Replace local image paths in markdown content with base64 data URLs.
|
|
546
558
|
#
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "digest"
|
|
5
|
+
require_relative "environment_detector"
|
|
6
|
+
|
|
7
|
+
module Clacky
|
|
8
|
+
module Utils
|
|
9
|
+
# Detects installed macOS applications able to open a given file type.
|
|
10
|
+
#
|
|
11
|
+
# Builds an extension → apps index by scanning application bundles and
|
|
12
|
+
# parsing their Info.plist document declarations (CFBundleDocumentTypes),
|
|
13
|
+
# then applies a static supplement for well-known apps whose cross-format
|
|
14
|
+
# abilities are not declared in the plist — e.g. Keynote opens ppt/pptx
|
|
15
|
+
# without listing them. Non-macOS hosts always answer with an empty list.
|
|
16
|
+
module MacAppDetector
|
|
17
|
+
SEARCH_DIRS = [
|
|
18
|
+
"/Applications",
|
|
19
|
+
"/System/Applications",
|
|
20
|
+
File.expand_path("~/Applications")
|
|
21
|
+
].freeze
|
|
22
|
+
|
|
23
|
+
# bundle name (without .app) => extensions it handles but does not
|
|
24
|
+
# declare in CFBundleDocumentTypes.
|
|
25
|
+
EXTRA_ABILITIES = {
|
|
26
|
+
"Keynote" => %w[key kth ppt pptx pot pps ppsx],
|
|
27
|
+
"Pages" => %w[pages doc docx dot rtf],
|
|
28
|
+
"Numbers" => %w[numbers xls xlsx csv],
|
|
29
|
+
"Preview" => %w[pdf png jpg jpeg gif tiff tif heic webp svg bmp],
|
|
30
|
+
"Microsoft PowerPoint" => %w[ppt pptx pot pps ppsx],
|
|
31
|
+
"Microsoft Word" => %w[doc docx dot rtf],
|
|
32
|
+
"Microsoft Excel" => %w[xls xlsx csv],
|
|
33
|
+
}.freeze
|
|
34
|
+
|
|
35
|
+
# @param ext [String] file extension, with or without leading dot
|
|
36
|
+
# @return [Array<Hash>] [{ "name" => ..., "path" => bundle path }]
|
|
37
|
+
def self.apps_for_ext(ext)
|
|
38
|
+
return [] unless Utils::EnvironmentDetector.os_type == :macos
|
|
39
|
+
|
|
40
|
+
ext = ext.to_s.downcase.sub(/\A\./, "")
|
|
41
|
+
return [] if ext.empty?
|
|
42
|
+
|
|
43
|
+
index[ext].sort_by { |a| a["name"] }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Resolve an app name or bundle path to a scanned bundle path.
|
|
47
|
+
# @return [String, nil]
|
|
48
|
+
def self.app_path(name_or_path)
|
|
49
|
+
return nil unless Utils::EnvironmentDetector.os_type == :macos
|
|
50
|
+
|
|
51
|
+
all_apps[name_or_path.to_s] || all_apps.key(name_or_path.to_s)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Return the application macOS would use to open `path` (the handler the
|
|
55
|
+
# plain `open` command would pick). Cached per file extension.
|
|
56
|
+
# @param path [String] absolute file path
|
|
57
|
+
# @return [Hash, nil] { "name" => ..., "path" => bundle path }
|
|
58
|
+
def self.default_app_for(path)
|
|
59
|
+
return nil unless Utils::EnvironmentDetector.os_type == :macos
|
|
60
|
+
|
|
61
|
+
ext = File.extname(path).downcase.sub(/\A\./, "")
|
|
62
|
+
return nil if ext.empty?
|
|
63
|
+
|
|
64
|
+
@default_app_cache ||= {}
|
|
65
|
+
return @default_app_cache[ext] if @default_app_cache.key?(ext)
|
|
66
|
+
|
|
67
|
+
bundle = default_app_bundle(path)
|
|
68
|
+
@default_app_cache[ext] =
|
|
69
|
+
if bundle && File.directory?(bundle)
|
|
70
|
+
{ "name" => File.basename(bundle, ".app"), "path" => bundle }
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Query LaunchServices for the default handler of `path` via NSWorkspace.
|
|
75
|
+
# @return [String, nil] bundle path (…/*.app)
|
|
76
|
+
def self.default_app_bundle(path)
|
|
77
|
+
script = <<~JXA
|
|
78
|
+
(function() {
|
|
79
|
+
ObjC.import("AppKit");
|
|
80
|
+
var url = $.NSURL.fileURLWithPath(#{path.to_json});
|
|
81
|
+
var app = $.NSWorkspace.sharedWorkspace.URLForApplicationToOpenURL(url);
|
|
82
|
+
return app ? app.path.js : "";
|
|
83
|
+
})()
|
|
84
|
+
JXA
|
|
85
|
+
out, _err, _status = Open3.capture3("osascript", "-l", "JavaScript", "-e", script)
|
|
86
|
+
result = out.strip
|
|
87
|
+
result.empty? ? nil : result
|
|
88
|
+
rescue StandardError
|
|
89
|
+
nil
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Convert an application bundle icon (.icns) to a cached 64px PNG via the
|
|
93
|
+
# system `sips` tool. Used by the Web UI "open with" dropdown.
|
|
94
|
+
# @param bundle_path [String] application bundle path (…/*.app)
|
|
95
|
+
# @return [String, nil] path to the cached PNG file
|
|
96
|
+
def self.icon_png(bundle_path)
|
|
97
|
+
return nil unless Utils::EnvironmentDetector.os_type == :macos
|
|
98
|
+
|
|
99
|
+
bundle = bundle_path.to_s
|
|
100
|
+
return nil unless bundle.end_with?(".app") && File.directory?(bundle)
|
|
101
|
+
|
|
102
|
+
cache_dir = File.join(Dir.home, "Library", "Caches", "openclacky", "app-icons")
|
|
103
|
+
cache = File.join(cache_dir, Digest::MD5.hexdigest(bundle) + ".png")
|
|
104
|
+
return cache if File.exist?(cache)
|
|
105
|
+
|
|
106
|
+
info = read_plist(File.join(bundle, "Contents", "Info.plist"))
|
|
107
|
+
return nil unless info
|
|
108
|
+
|
|
109
|
+
icon = info["CFBundleIconFile"].to_s
|
|
110
|
+
return nil if icon.empty?
|
|
111
|
+
icon += ".icns" unless icon =~ /\.icns\z/i
|
|
112
|
+
icns = File.join(bundle, "Contents", "Resources", icon)
|
|
113
|
+
return nil unless File.exist?(icns)
|
|
114
|
+
|
|
115
|
+
require "fileutils"
|
|
116
|
+
FileUtils.mkdir_p(cache_dir)
|
|
117
|
+
ok = system("sips", "-s", "format", "png", "--resampleWidth", "64",
|
|
118
|
+
icns, "--out", cache, out: File::NULL, err: File::NULL)
|
|
119
|
+
return nil unless ok && File.exist?(cache)
|
|
120
|
+
cache
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Open a file with a specific installed application (macOS only).
|
|
124
|
+
# @param path [String] absolute file path
|
|
125
|
+
# @param app [String] app name or bundle path, must be a scanned app
|
|
126
|
+
# @return [Boolean, nil] system() result, nil when the app is unknown
|
|
127
|
+
def self.open_with(path, app)
|
|
128
|
+
bundle = app_path(app.to_s)
|
|
129
|
+
return nil unless bundle
|
|
130
|
+
system("open", "-a", bundle, path)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def self.all_apps
|
|
134
|
+
return @all_apps if defined?(@all_apps)
|
|
135
|
+
|
|
136
|
+
@all_apps = {}
|
|
137
|
+
SEARCH_DIRS.each do |dir|
|
|
138
|
+
next unless File.directory?(dir)
|
|
139
|
+
Dir.glob(File.join(dir, "*.app")).each do |bundle|
|
|
140
|
+
@all_apps[File.basename(bundle, ".app")] = bundle
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
@all_apps
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def self.index
|
|
147
|
+
return @index if defined?(@index)
|
|
148
|
+
|
|
149
|
+
@index = Hash.new { |h, k| h[k] = [] }
|
|
150
|
+
|
|
151
|
+
all_apps.each do |name, bundle|
|
|
152
|
+
info = read_plist(File.join(bundle, "Contents", "Info.plist"))
|
|
153
|
+
next unless info
|
|
154
|
+
(info["CFBundleDocumentTypes"] || []).each do |dt|
|
|
155
|
+
(dt["CFBundleTypeExtensions"] || []).each do |e|
|
|
156
|
+
add(@index, e.to_s.downcase, name, bundle)
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
EXTRA_ABILITIES.each do |name, exts|
|
|
162
|
+
bundle = all_apps[name]
|
|
163
|
+
next unless bundle
|
|
164
|
+
exts.each { |e| add(@index, e, name, bundle) }
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
@index
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def self.add(index, ext, name, bundle)
|
|
171
|
+
return if ext.empty?
|
|
172
|
+
list = index[ext]
|
|
173
|
+
list << { "name" => name, "path" => bundle } unless list.any? { |a| a["path"] == bundle }
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def self.read_plist(path)
|
|
177
|
+
out, _err, _status = Open3.capture3("plutil", "-convert", "json", "-o", "-", path)
|
|
178
|
+
return nil if out.nil? || out.empty?
|
|
179
|
+
require "json"
|
|
180
|
+
JSON.parse(out)
|
|
181
|
+
rescue StandardError
|
|
182
|
+
nil
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|