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.
- checksums.yaml +7 -0
- data/.rspec +4 -0
- data/CHANGELOG.md +8 -0
- data/Gemfile +12 -0
- data/Gemfile.lock +37 -0
- data/README.md +262 -0
- data/Rakefile +97 -0
- data/bin/dockedit +10 -0
- data/build_standalone.rb +88 -0
- data/dockedit +1461 -0
- data/lib/dockedit/app_finder.rb +46 -0
- data/lib/dockedit/cli.rb +166 -0
- data/lib/dockedit/commands.rb +372 -0
- data/lib/dockedit/constants.rb +44 -0
- data/lib/dockedit/dock.rb +98 -0
- data/lib/dockedit/folder_updater.rb +58 -0
- data/lib/dockedit/matcher.rb +136 -0
- data/lib/dockedit/parsers.rb +62 -0
- data/lib/dockedit/path_utils.rb +44 -0
- data/lib/dockedit/plist_reader.rb +158 -0
- data/lib/dockedit/plist_writer.rb +52 -0
- data/lib/dockedit/tile_factory.rb +189 -0
- data/lib/dockedit/version.rb +7 -0
- data/lib/dockedit.rb +25 -0
- metadata +82 -0
|
@@ -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
|
+
|