dockedit 1.0.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.
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DockEdit
4
+ # Main class for managing Dock state and high-level operations.
5
+ #
6
+ # A {Dock} instance wraps a parsed Dock plist and provides helpers for
7
+ # locating and mutating app and folder tiles.
8
+ class Dock
9
+ attr_reader :doc, :apps_array, :others_array
10
+
11
+ # Create a new Dock wrapper around the current Dock plist.
12
+ #
13
+ # The plist is loaded via {PlistReader.load_dock_plist} and the
14
+ # `persistent-apps` and `persistent-others` arrays are extracted.
15
+ #
16
+ # @raise [SystemExit] Exits with status 1 if the arrays cannot be found.
17
+ def initialize
18
+ @doc = PlistReader.load_dock_plist
19
+ @apps_array = PlistReader.get_persistent_apps(@doc)
20
+ @others_array = PlistReader.get_persistent_others(@doc)
21
+
22
+ unless @apps_array && @others_array
23
+ $stderr.puts "Error: Could not find dock arrays in plist"
24
+ exit 1
25
+ end
26
+ end
27
+
28
+ # Persist changes to the Dock plist and restart the Dock.
29
+ #
30
+ # @param messages [String, Array<String>] Message or messages to print.
31
+ # @return [void]
32
+ def save(messages)
33
+ PlistWriter.write_plist_and_restart(@doc, messages)
34
+ end
35
+
36
+ # Find an application tile by name or bundle identifier.
37
+ #
38
+ # @param query [String] Search term.
39
+ # @return [Array<(Integer, String)>] A pair of `[index, display_name]`
40
+ # from the apps array, or `[nil, nil]` if not found.
41
+ def find_app(query)
42
+ app_dicts = @apps_array.elements.to_a.select { |e| e.is_a?(REXML::Element) && e.name == 'dict' }
43
+ Matcher.find_app_index(app_dicts, query)
44
+ end
45
+
46
+ # Find a folder tile by name, bundle identifier, or path.
47
+ #
48
+ # @param query [String] Search term (name or path).
49
+ # @param check_url [Boolean] Whether to match against the folder URL path.
50
+ # @return [Array<(Integer, String)>] A pair of `[index, display_name]`
51
+ # from the folders array, or `[nil, nil]` if not found.
52
+ def find_folder(query, check_url: false)
53
+ folder_dicts = @others_array.elements.to_a.select { |e| e.is_a?(REXML::Element) && e.name == 'dict' }
54
+ Matcher.find_item_index(folder_dicts, query, check_url: check_url)
55
+ end
56
+
57
+ # Find a tile in either the apps or folders section.
58
+ #
59
+ # Apps are searched first; if no match is found, folders are checked.
60
+ #
61
+ # @param query [String] Search term.
62
+ # @param check_url [Boolean] Whether to match against folder URL paths.
63
+ # @return [Array] A triple of `[array, element, display_name]`, or
64
+ # `[nil, nil, nil]` if nothing matches.
65
+ def find_item(query, check_url: false)
66
+ # Try apps first
67
+ index, name = find_app(query)
68
+ if index
69
+ app_dicts = @apps_array.elements.to_a.select { |e| e.is_a?(REXML::Element) && e.name == 'dict' }
70
+ return [@apps_array, app_dicts[index], name]
71
+ end
72
+
73
+ # Try folders
74
+ index, name = find_folder(query, check_url: check_url)
75
+ if index
76
+ folder_dicts = @others_array.elements.to_a.select { |e| e.is_a?(REXML::Element) && e.name == 'dict' }
77
+ return [@others_array, folder_dicts[index], name]
78
+ end
79
+
80
+ [nil, nil, nil]
81
+ end
82
+
83
+ # Return all app tile `<dict>` elements.
84
+ #
85
+ # @return [Array<REXML::Element>]
86
+ def get_app_dicts
87
+ @apps_array.elements.to_a.select { |e| e.is_a?(REXML::Element) && e.name == 'dict' }
88
+ end
89
+
90
+ # Return all folder tile `<dict>` elements.
91
+ #
92
+ # @return [Array<REXML::Element>]
93
+ def get_folder_dicts
94
+ @others_array.elements.to_a.select { |e| e.is_a?(REXML::Element) && e.name == 'dict' }
95
+ end
96
+ end
97
+ end
98
+
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DockEdit
4
+ # Handles updating folder tile properties in the Dock plist.
5
+ #
6
+ # These helpers mutate existing folder tiles to change view and style
7
+ # without recreating them.
8
+ class FolderUpdater
9
+ # Update the +showas+ value for an existing folder tile.
10
+ #
11
+ # @param folder_dict [REXML::Element] Folder tile `<dict>` element.
12
+ # @param show_as [Integer] New show-as value (1=fan, 2=grid, 3=list, 4=auto).
13
+ # @return [Boolean] +true+ if the value was updated or added.
14
+ def self.update_folder_showas(folder_dict, show_as)
15
+ update_folder_integer_key(folder_dict, 'showas', show_as)
16
+ end
17
+
18
+ # Update the +displayas+ value for an existing folder tile.
19
+ #
20
+ # @param folder_dict [REXML::Element] Folder tile `<dict>` element.
21
+ # @param display_as [Integer] New display-as value (0=stack, 1=folder).
22
+ # @return [Boolean] +true+ if the value was updated or added.
23
+ def self.update_folder_displayas(folder_dict, display_as)
24
+ update_folder_integer_key(folder_dict, 'displayas', display_as)
25
+ end
26
+
27
+ # Update an integer key inside folder tile-data.
28
+ #
29
+ # If the key already exists its integer value is replaced; otherwise a new
30
+ # key/value pair is appended.
31
+ #
32
+ # @param folder_dict [REXML::Element] Folder tile `<dict>` element.
33
+ # @param key_name [String] Name of the integer key inside +tile-data+.
34
+ # @param value [Integer] New integer value to set.
35
+ # @return [Boolean] +true+ if the value was updated or added, +false+ if
36
+ # no +tile-data+ section could be found.
37
+ def self.update_folder_integer_key(folder_dict, key_name, value)
38
+ tile_data = PlistReader.get_tile_data(folder_dict)
39
+ return false unless tile_data
40
+
41
+ # Find and update the key
42
+ current_key = nil
43
+ tile_data.elements.each do |elem|
44
+ if elem.name == 'key' && elem.text == key_name
45
+ current_key = elem
46
+ elsif current_key && elem.name == 'integer'
47
+ elem.text = value.to_s
48
+ return true
49
+ end
50
+ end
51
+
52
+ # If key doesn't exist, add it
53
+ TileFactory.add_plist_key_value(tile_data, key_name, 'integer', value.to_s)
54
+ true
55
+ end
56
+ end
57
+ end
58
+
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DockEdit
4
+ # Handles fuzzy matching for application names and Dock items.
5
+ #
6
+ # Matching is based on a simple scoring system that prefers exact matches,
7
+ # then "starts with", then substring and abbreviation matches.
8
+ class Matcher
9
+ # Calculate a fuzzy match score between two strings.
10
+ #
11
+ # A lower score means a better match. +nil+ indicates no match at all.
12
+ #
13
+ # @param query [String] User-entered search text.
14
+ # @param target [String] Candidate string to score against.
15
+ # @return [Integer, nil] Score where 0 is best, or +nil+ when there is no match.
16
+ def self.match_score(query, target)
17
+ return nil if target.nil? || target.empty?
18
+
19
+ query = query.downcase
20
+ target = target.downcase
21
+
22
+ # Exact match - best score
23
+ return 0 if target == query
24
+
25
+ # Starts with query
26
+ return 1 if target.start_with?(query)
27
+
28
+ # Contains match - score based on position
29
+ if target.include?(query)
30
+ return 2 + target.index(query)
31
+ end
32
+
33
+ # Abbreviation match (e.g., "vscode" matches "Visual Studio Code")
34
+ query_chars = query.chars
35
+ target_pos = 0
36
+ matched = true
37
+
38
+ query_chars.each do |char|
39
+ found = false
40
+ while target_pos < target.length
41
+ if target[target_pos] == char
42
+ found = true
43
+ target_pos += 1
44
+ break
45
+ end
46
+ target_pos += 1
47
+ end
48
+ unless found
49
+ matched = false
50
+ break
51
+ end
52
+ end
53
+
54
+ return 100 + target.length if matched
55
+
56
+ nil
57
+ end
58
+
59
+ # Check whether +target+ fuzzily matches +query+.
60
+ #
61
+ # @param query [String]
62
+ # @param target [String]
63
+ # @return [Boolean] +true+ if {#match_score} returns a non-nil score.
64
+ def self.fuzzy_match?(query, target)
65
+ !match_score(query, target).nil?
66
+ end
67
+
68
+ # Find the best-matching item index in a Dock array.
69
+ #
70
+ # This scans a list of Dock tile `<dict>` elements and finds the index
71
+ # whose label, bundle identifier, or (optionally) URL path best matches
72
+ # +query+.
73
+ #
74
+ # @param items_array [Array<REXML::Element>] Array of tile `<dict>` elements.
75
+ # @param query [String] Search term (app name, bundle id, or path fragment).
76
+ # @param check_url [Boolean] Whether to also consider the folder URL path.
77
+ # @return [Array<(Integer, String)>] A pair of `[index, display_name]`, where
78
+ # +index+ is the best entry index or +nil+ when nothing matches.
79
+ def self.find_item_index(items_array, query, check_url: false)
80
+ best_score = nil
81
+ best_name = nil
82
+ best_index = nil
83
+
84
+ items_array.each_with_index do |item_dict, index|
85
+ next unless item_dict.is_a?(REXML::Element) && item_dict.name == 'dict'
86
+
87
+ tile_data = PlistReader.get_tile_data(item_dict)
88
+ next unless tile_data
89
+
90
+ file_label = PlistReader.get_tile_value(tile_data, 'file-label')
91
+ bundle_id = PlistReader.get_tile_value(tile_data, 'bundle-identifier')
92
+
93
+ scores = []
94
+ scores << match_score(query, file_label)
95
+ scores << match_score(query, bundle_id)
96
+
97
+ # For folders, also check URL path
98
+ if check_url
99
+ url_string = PlistReader.get_file_data_url(tile_data)
100
+ if url_string
101
+ # Extract path from file:// URL and decode
102
+ path = URI.decode_www_form_component(url_string.sub(%r{^file://}, '').chomp('/'))
103
+ basename = File.basename(path)
104
+ scores << match_score(query, path)
105
+ scores << match_score(query, basename)
106
+ end
107
+ end
108
+
109
+ current_score = scores.compact.min
110
+ next unless current_score
111
+
112
+ name = file_label || bundle_id || 'Unknown'
113
+ name_length = name.length
114
+
115
+ if best_score.nil? || current_score < best_score ||
116
+ (current_score == best_score && name_length < (best_name&.length || 999))
117
+ best_score = current_score
118
+ best_name = name
119
+ best_index = index
120
+ end
121
+ end
122
+
123
+ [best_index, best_name]
124
+ end
125
+
126
+ # Backwards-compatible alias for finding app indices.
127
+ #
128
+ # @param apps_array [Array<REXML::Element>] App tile `<dict>` elements.
129
+ # @param query [String] Search term.
130
+ # @return [Array<(Integer, String)>] See {#find_item_index}.
131
+ def self.find_app_index(apps_array, query)
132
+ find_item_index(apps_array, query, check_url: false)
133
+ end
134
+ end
135
+ end
136
+
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DockEdit
4
+ # Utility functions for parsing and formatting Dock-related values.
5
+ module Parsers
6
+ include Constants
7
+
8
+ # Parse a show-as argument into an integer value.
9
+ #
10
+ # Accepts symbolic forms like "fan", "grid", "list", "auto" (and their
11
+ # single-letter aliases) and returns the corresponding integer constant.
12
+ #
13
+ # @param value [String, nil] Raw user input.
14
+ # @return [Integer, nil] Parsed show-as value, or +nil+ if +value+ is +nil+.
15
+ def self.parse_show_as(value)
16
+ return nil if value.nil?
17
+
18
+ key = value.downcase
19
+ SHOW_AS_VALUES[key] || 4
20
+ end
21
+
22
+ # Convert a numeric show-as value into a human-readable name.
23
+ #
24
+ # @param value [Integer] Numeric show-as value.
25
+ # @return [String] One of "fan", "grid", "list", or "auto".
26
+ def self.show_as_name(value)
27
+ case value
28
+ when 1 then 'fan'
29
+ when 2 then 'grid'
30
+ when 3 then 'list'
31
+ when 4 then 'auto'
32
+ else 'auto'
33
+ end
34
+ end
35
+
36
+ # Parse a display-as argument into an integer value.
37
+ #
38
+ # Accepts "stack"/"s" and "folder"/"f".
39
+ #
40
+ # @param value [String, nil] Raw user input.
41
+ # @return [Integer, nil] Parsed display-as value, or +nil+ if invalid or +nil+.
42
+ def self.parse_display_as(value)
43
+ return nil if value.nil?
44
+
45
+ key = value.downcase
46
+ DISPLAY_AS_VALUES[key]
47
+ end
48
+
49
+ # Convert a numeric display-as value into a human-readable name.
50
+ #
51
+ # @param value [Integer] Numeric display-as value.
52
+ # @return [String] Either "stack" or "folder".
53
+ def self.display_as_name(value)
54
+ case value
55
+ when 0 then 'stack'
56
+ when 1 then 'folder'
57
+ else 'stack'
58
+ end
59
+ end
60
+ end
61
+ end
62
+
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DockEdit
4
+ # Utility functions for handling user-supplied filesystem paths.
5
+ module PathUtils
6
+ include Constants
7
+
8
+ # Expand a path shortcut or user path into a full filesystem path.
9
+ #
10
+ # Recognizes shortcuts such as "desktop", "downloads", "home", "~",
11
+ # "applications", etc., falling back to +File.expand_path+.
12
+ #
13
+ # @param path [String] Raw path or shortcut.
14
+ # @return [String] Expanded absolute path with trailing slash removed.
15
+ def self.expand_path_shortcut(path)
16
+ PATH_SHORTCUTS.each do |pattern, expanded|
17
+ return expanded if path.match?(pattern)
18
+ end
19
+ # Not a shortcut, expand ~ and return
20
+ File.expand_path(path).chomp('/')
21
+ end
22
+
23
+ # Determine whether the given path refers to a folder (not an app bundle).
24
+ #
25
+ # This respects path shortcuts and returns +true+ only for existing
26
+ # directories that do not end in ".app".
27
+ #
28
+ # @param path [String] Raw path or shortcut.
29
+ # @return [Boolean]
30
+ def self.folder_path?(path)
31
+ expanded = expand_path_shortcut(path)
32
+ File.directory?(expanded) && !expanded.end_with?('.app')
33
+ end
34
+
35
+ # Check whether the given string looks like an explicit app bundle path.
36
+ #
37
+ # @param path [String]
38
+ # @return [Boolean] +true+ if the string ends with ".app" and contains a '/'.
39
+ def self.explicit_app_path?(path)
40
+ path.end_with?('.app') && path.include?('/')
41
+ end
42
+ end
43
+ end
44
+
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DockEdit
4
+ # Handles reading and parsing Dock and app plist data.
5
+ class PlistReader
6
+ include Constants
7
+
8
+ # Extract the +tile-data+ `<dict>` from a Dock tile `<dict>`.
9
+ #
10
+ # @param app_dict [REXML::Element] Tile `<dict>` element.
11
+ # @return [REXML::Element, nil] The nested +tile-data+ dict, or +nil+.
12
+ def self.get_tile_data(app_dict)
13
+ current_key = nil
14
+ app_dict.elements.each do |elem|
15
+ if elem.name == 'key'
16
+ current_key = elem.text
17
+ elsif elem.name == 'dict' && current_key == 'tile-data'
18
+ return elem
19
+ end
20
+ end
21
+ nil
22
+ end
23
+
24
+ # Look up a string value in a tile-data `<dict>`.
25
+ #
26
+ # @param tile_data [REXML::Element] Tile-data `<dict>`.
27
+ # @param key_name [String] Name of the key to retrieve.
28
+ # @return [String, nil] The associated string value, or +nil+.
29
+ def self.get_tile_value(tile_data, key_name)
30
+ current_key = nil
31
+ tile_data.elements.each do |elem|
32
+ if elem.name == 'key'
33
+ current_key = elem.text
34
+ elsif current_key == key_name
35
+ return elem.text if elem.name == 'string'
36
+ return nil
37
+ end
38
+ end
39
+ nil
40
+ end
41
+
42
+ # Extract the `_CFURLString` from the nested +file-data+ dict.
43
+ #
44
+ # @param tile_data [REXML::Element] Tile-data `<dict>`.
45
+ # @return [String, nil] The URL string, or +nil+ if none is present.
46
+ def self.get_file_data_url(tile_data)
47
+ current_key = nil
48
+ tile_data.elements.each do |elem|
49
+ if elem.name == 'key'
50
+ current_key = elem.text
51
+ elsif elem.name == 'dict' && current_key == 'file-data'
52
+ return get_tile_value(elem, '_CFURLString')
53
+ end
54
+ end
55
+ nil
56
+ end
57
+
58
+ # Get the `persistent-apps` array from a Dock plist document.
59
+ #
60
+ # @param doc [REXML::Document]
61
+ # @return [REXML::Element, nil] The `<array>` element, or +nil+.
62
+ def self.get_persistent_apps(doc)
63
+ get_plist_array(doc, 'persistent-apps')
64
+ end
65
+
66
+ # Get the `persistent-others` array from a Dock plist document.
67
+ #
68
+ # @param doc [REXML::Document]
69
+ # @return [REXML::Element, nil] The `<array>` element, or +nil+.
70
+ def self.get_persistent_others(doc)
71
+ get_plist_array(doc, 'persistent-others')
72
+ end
73
+
74
+ # Get a named array from the Dock plist root `<dict>`.
75
+ #
76
+ # @param doc [REXML::Document]
77
+ # @param array_name [String] Name of the array key.
78
+ # @return [REXML::Element, nil] The `<array>` element, or +nil+.
79
+ def self.get_plist_array(doc, array_name)
80
+ root_dict = doc.root.elements['dict']
81
+ return nil unless root_dict
82
+
83
+ current_key = nil
84
+ root_dict.elements.each do |elem|
85
+ if elem.name == 'key'
86
+ current_key = elem.text
87
+ elsif elem.name == 'array' && current_key == array_name
88
+ return elem
89
+ end
90
+ end
91
+
92
+ nil
93
+ end
94
+
95
+ # Load and parse the Dock plist as XML.
96
+ #
97
+ # The file at {DockEdit::Constants::DOCK_PLIST} is converted to XML form
98
+ # using +plutil+ before being read.
99
+ #
100
+ # @return [REXML::Document] Parsed Dock plist document.
101
+ def self.load_dock_plist
102
+ unless system("plutil -convert xml1 '#{DOCK_PLIST}' 2>/dev/null")
103
+ $stderr.puts "Error: Failed to convert Dock plist to XML"
104
+ exit 1
105
+ end
106
+
107
+ plist_content = File.read(DOCK_PLIST)
108
+ REXML::Document.new(plist_content)
109
+ end
110
+
111
+ # Read and parse +Info.plist+ from an app bundle.
112
+ #
113
+ # The plist is copied to a temporary file and converted to XML before
114
+ # parsing. Selected keys are extracted into a flat Ruby hash.
115
+ #
116
+ # @param app_path [String] Absolute path to an `.app` bundle.
117
+ # @return [Hash, nil] Hash of plist keys to values, or +nil+ if the
118
+ # plist cannot be read.
119
+ def self.read_app_info(app_path)
120
+ info_plist = File.join(app_path, 'Contents', 'Info.plist')
121
+ return nil unless File.exist?(info_plist)
122
+
123
+ # Convert to XML and read
124
+ temp_plist = "/tmp/dockedit_info_#{$$}.plist"
125
+ FileUtils.cp(info_plist, temp_plist)
126
+ system("plutil -convert xml1 '#{temp_plist}' 2>/dev/null")
127
+
128
+ content = File.read(temp_plist)
129
+ File.delete(temp_plist) if File.exist?(temp_plist)
130
+
131
+ doc = REXML::Document.new(content)
132
+ root_dict = doc.root.elements['dict']
133
+ return nil unless root_dict
134
+
135
+ info = {}
136
+ current_key = nil
137
+
138
+ root_dict.elements.each do |elem|
139
+ if elem.name == 'key'
140
+ current_key = elem.text
141
+ elsif current_key
142
+ case elem.name
143
+ when 'string'
144
+ info[current_key] = elem.text
145
+ when 'array'
146
+ # For arrays, get first string element
147
+ first_string = elem.elements['string']
148
+ info[current_key] = first_string.text if first_string
149
+ end
150
+ current_key = nil
151
+ end
152
+ end
153
+
154
+ info
155
+ end
156
+ end
157
+ end
158
+
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DockEdit
4
+ # Handles writing Dock plist data and restarting the Dock process.
5
+ class PlistWriter
6
+ include Constants
7
+
8
+ # Write the modified Dock plist and restart the Dock.
9
+ #
10
+ # The provided document is pretty-printed, written to
11
+ # {DockEdit::Constants::DOCK_PLIST}, converted back to binary format,
12
+ # and the Dock process is restarted. Success messages are printed and
13
+ # the process exits with status 0. On error, a message is printed and
14
+ # the process exits with status 1.
15
+ #
16
+ # @param doc [REXML::Document] Modified Dock plist document.
17
+ # @param success_messages [String, Array<String>] Message or messages to
18
+ # print after a successful write.
19
+ # @return [void]
20
+ def self.write_plist_and_restart(doc, success_messages)
21
+ formatter = REXML::Formatters::Pretty.new(2)
22
+ formatter.compact = true
23
+
24
+ output = StringIO.new
25
+ output << %{<?xml version="1.0" encoding="UTF-8"?>\n}
26
+ output << %{<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n}
27
+ formatter.write(doc.root, output)
28
+ output << "\n"
29
+
30
+ begin
31
+ File.write(DOCK_PLIST, output.string)
32
+ rescue => e
33
+ $stderr.puts "Error: Failure to update Dock plist - #{e.message}"
34
+ exit 1
35
+ end
36
+
37
+ # Convert back to binary and restart Dock
38
+ unless system("plutil -convert binary1 '#{DOCK_PLIST}' 2>/dev/null")
39
+ $stderr.puts "Error: Failure to update Dock plist"
40
+ exit 1
41
+ end
42
+
43
+ system('killall Dock')
44
+
45
+ # Handle single message or array of messages
46
+ messages = success_messages.is_a?(Array) ? success_messages : [success_messages]
47
+ messages.each { |msg| puts msg }
48
+ exit 0
49
+ end
50
+ end
51
+ end
52
+