booker 1.3.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,225 @@
1
+ # frozen_string_literal: true
2
+
3
+ # configuation - which platform we are on, how to open a link, and where the
4
+ # bookmarks live
5
+
6
+ require "yaml"
7
+
8
+ require_relative "output"
9
+
10
+ using Booker::Colors
11
+
12
+ module Booker
13
+ # detect operating system
14
+ module OS
15
+ def self.windows? = RUBY_PLATFORM.match?(/cygwin|mswin|mingw|bccwin|wince|emx/)
16
+
17
+ def self.mac? = RUBY_PLATFORM.match?(/darwin/)
18
+
19
+ def self.linux? = !(windows? || mac?)
20
+ end
21
+
22
+ # return browser (chrome) opening command
23
+ module Browser
24
+ def browse
25
+ if OS.windows?
26
+ # alternatively, start seems to work - probably check if powershell v cygwin?
27
+ '/cygdrive/c/Program\ Files\ \(x86\)/Google/Chrome/Application/chrome.exe '
28
+ elsif OS.mac?
29
+ "open "
30
+ else
31
+ # linux? is defined as "neither of the above", so there is no fourth
32
+ # branch for #browse to fall out of and return nil from
33
+ "xdg-open "
34
+ end
35
+ end
36
+
37
+ # what an explicit scheme looks like. #domain and #prep are two halves of
38
+ # one grammar - is this a url, does it already say how to fetch it - so both
39
+ # read the rule from here rather than spelling it out twice and drifting
40
+ SCHEME = /[a-z][a-z0-9+.-]*:\/\//i
41
+
42
+ # does this argument look like a website rather than a search term? an
43
+ # explicit scheme, or a bare host with a dot and an alphabetic tld. every
44
+ # tld has to work: completion inserts real bookmark urls, and the old
45
+ # (io|com|net|org|...) list sent .dev and .ai off to the search engine
46
+ def domain
47
+ %r{\A(?:#{SCHEME}\S+|(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}(?::\d+)?(?:[/?#]\S*)?)\z}io
48
+ end
49
+
50
+ # only a bare host needs a scheme invented for it. testing for "http" alone
51
+ # sent everything else down the same branch, so a completed
52
+ # chrome://bookmarks/ came out as http://chrome://bookmarks/
53
+ def prep(url)
54
+ /\A#{SCHEME}/io.match?(url) ? url : "http://" + url
55
+ end
56
+ end
57
+
58
+ # configuration
59
+ class Config
60
+ VALID = [:searcher, :bookmarks, :browser, :picker].freeze
61
+ HOME = ENV.fetch("HOME", "/usr/local/").freeze
62
+ YAMLCONF = (HOME + "/.booker.yml").freeze
63
+
64
+ class << self
65
+ # one instance per run. Bookmarks, Picker and the CLI want the same
66
+ # answers, and one Config apiece meant parsing the yaml three times,
67
+ # discovering three times, and warning three times to say it once
68
+ def default = @default ||= new
69
+
70
+ # the memo outlives a single example, so the suite drops it between them
71
+ def reset! = @default = nil
72
+ end
73
+
74
+ # quiet_missing is for --install, which is about to write the very file the
75
+ # warning would tell the user to go and create
76
+ def initialize(quiet_missing: false)
77
+ @quiet_missing = quiet_missing
78
+
79
+ # the yaml file if there is one, and only work out the defaults when
80
+ # there is not: discovery walks every chrome profile directory to answer
81
+ # a question the config file has already answered
82
+ @config = read(YAMLCONF) || {
83
+ browser: "open ",
84
+ searcher: "https://google.com/search?q=",
85
+ bookmarks: detect_default_bookmarks
86
+ }
87
+
88
+ # prune bad config keys
89
+ @config.each_key do |k|
90
+ if !VALID.include? k.to_sym
91
+ warn "Failure:".red + " Bad key found in config file: #{k}"
92
+ exit 1
93
+ end
94
+ end
95
+ end
96
+
97
+ def detect_default_bookmarks
98
+ # Return ALL available bookmark sources (Chrome, Firefox, Safari)
99
+ all_sources = discover_all_bookmark_sources
100
+
101
+ # If we found sources, return them all; otherwise return a default single path
102
+ if all_sources.empty?
103
+ # Fallback to macOS Chrome default for backward compatibility
104
+ HOME + "/Library/Application Support/Google/Chrome/Profile 1/Bookmarks"
105
+ else
106
+ all_sources
107
+ end
108
+ end
109
+
110
+ def discover_all_bookmark_sources = Sources.discover
111
+
112
+ def read(file)
113
+ # #write emits symbol keys, so they have to be permitted on the way back
114
+ YAML.safe_load_file(file, permitted_classes: [Symbol])
115
+ rescue Errno::ENOENT
116
+ unless @quiet_missing
117
+ warn "Warning: ".yel +
118
+ "YAML configuration file couldn't be found. Using defaults."
119
+ warn "Suggest: ".grn + "booker --install config"
120
+ end
121
+ false
122
+ rescue Psych::Exception
123
+ # every psych failure means the same thing here - an unusable config to
124
+ # fall back from rather than crash on. rescuing SyntaxError alone let
125
+ # anchors through as an AliasesNotEnabled backtrace under psych 4
126
+ warn "Warning: ".red +
127
+ "YAML configuration file could not be read. Using defaults."
128
+ false
129
+ end
130
+
131
+ # used for creating and updating the default configuration
132
+ def write(k = nil, v = nil)
133
+ @config[k] = v unless k.nil? # update users yaml config file
134
+ File.write(YAMLCONF, @config.to_yaml)
135
+ end
136
+
137
+ # always an array, so callers never branch on it: configs written before
138
+ # multi-source support hold a single path string
139
+ def bookmarks = Array(@config[:bookmarks])
140
+
141
+ def searcher = @config[:searcher]
142
+
143
+ # the finder command with its flags, split with Shellwords rather than
144
+ # handed to a shell. nil means "auto detect fzf", which is why #write leaves
145
+ # it out of the defaults: #initialize exits on keys it does not know, so a
146
+ # config naming it is unreadable by a booker predating the key
147
+ def picker = @config[:picker]
148
+ end
149
+
150
+ # where browsers keep their bookmarks. one implementation, shared by the
151
+ # auto-detection Config falls back on and by `booker --install bookmarks` -
152
+ # the installer's job is to show what auto-detection would find, so a second
153
+ # copy of these paths guarantees it eventually shows something else. nothing
154
+ # here tags a source: Bookmarks.source_for reads that off the filename
155
+ module Sources
156
+ CHROME = [
157
+ "/Library/Application Support/Google/Chrome", # macOS
158
+ "/Library/Application Support/Chromium", # macOS Chromium
159
+ "/.config/google-chrome", # Linux
160
+ "/.config/chromium", # Linux Chromium
161
+ "/snap/chromium/common/chromium", # Linux (snap)
162
+ "/snap/chromium/current/.config/chromium", # Linux (snap alt)
163
+ "/AppData/Local/Google/Chrome/User Data" # Windows
164
+ ].freeze
165
+
166
+ FIREFOX = [
167
+ "/.mozilla/firefox", # Linux
168
+ "/snap/firefox/common/.mozilla/firefox", # Linux (snap)
169
+ "/Library/Application Support/Firefox", # macOS
170
+ "/AppData/Roaming/Mozilla/Firefox" # Windows
171
+ ].freeze
172
+
173
+ SAFARI = "/Library/Safari/Bookmarks.plist"
174
+
175
+ class << self
176
+ def discover = (chrome + firefox + safari).uniq
177
+
178
+ # every places.sqlite named by a profiles.ini. any Path= counts, and the
179
+ # [Install...] block firefox also writes names a profile already listed,
180
+ # hence the uniq
181
+ def profiles(ini_path, base)
182
+ File.readlines(ini_path).filter_map do |line|
183
+ key, value = line.strip.split("=", 2)
184
+ next unless key == "Path" && value
185
+
186
+ db = File.join(base, value.strip, "places.sqlite")
187
+ db if File.exist?(db)
188
+ end.uniq
189
+ end
190
+
191
+ private
192
+
193
+ def chrome
194
+ expand(CHROME).flat_map do |base|
195
+ # one level down, not "**": chrome puts Bookmarks directly inside a
196
+ # profile directory, and the rest of the tree is Cache and Service
197
+ # Worker storage - tens of thousands of files to walk for nothing. the
198
+ # second pattern covers a base that already names a profile. globbing
199
+ # relative to base keeps a metacharacter in the path itself - a
200
+ # profile named "Chrome [work]", say - out of the pattern
201
+ Dir.glob(["Bookmarks", "*/Bookmarks"], base: base)
202
+ .map { |relative| File.join(base, relative) }
203
+ .select { |file| File.file?(file) }
204
+ rescue
205
+ # Skip if we can't read this directory
206
+ []
207
+ end
208
+ end
209
+
210
+ def firefox
211
+ expand(FIREFOX).flat_map do |base|
212
+ ini = File.join(base, "profiles.ini")
213
+ File.exist?(ini) ? profiles(ini, base) : []
214
+ end
215
+ end
216
+
217
+ def safari
218
+ path = Config::HOME + SAFARI
219
+ File.exist?(path) ? [path] : []
220
+ end
221
+
222
+ def expand(bases) = bases.map { |b| Config::HOME + b }.select { |b| Dir.exist?(b) }
223
+ end
224
+ end
225
+ end
@@ -0,0 +1,403 @@
1
+ # frozen_string_literal: true
2
+
3
+ # everything behind `booker --install`: shell completion, the config file,
4
+ # finding browser bookmarks, and the opt-in safari permission walkthrough
5
+
6
+ require "fileutils"
7
+ require "open3"
8
+
9
+ require_relative "output"
10
+
11
+ using Booker::Colors
12
+
13
+ module Booker
14
+ class Installer
15
+ include Output
16
+
17
+ # shells we ship completion for, and where those scripts live
18
+ SHELLS = %w[zsh bash fish].freeze
19
+
20
+ # what ~/.zshrc needs for a script in ~/.zsh/completion to be found
21
+ ZSHRC_LINES = [
22
+ "fpath=(~/.zsh/completion $fpath)",
23
+ "autoload -Uz compinit && compinit"
24
+ ].freeze
25
+ COMPLETIONS_DIR = File.expand_path("../../completions", __dir__).freeze
26
+
27
+ # if any of these exist, bash-completion is installed and will autoload our
28
+ # script out of the XDG user directory
29
+ BASH_COMPLETION_MARKERS = [
30
+ "/usr/share/bash-completion/bash_completion",
31
+ "/etc/bash_completion",
32
+ "/usr/local/etc/profile.d/bash_completion.sh",
33
+ "/opt/homebrew/etc/profile.d/bash_completion.sh"
34
+ ].freeze
35
+
36
+ # 'all' expands to the full install list (including opt-in safari)
37
+ ALL = %w[completion config bookmarks safari].freeze
38
+
39
+ def install(args)
40
+ args.flat_map { |target| /^all$/i.match?(target) ? ALL : target }
41
+ .each { |target| install_one(target) }
42
+ end
43
+
44
+ def install_one(target)
45
+ if /comp/i.match?(target) # completion for every shell on this machine
46
+ install_completion
47
+ elsif (shell = SHELLS.find { |s| target.downcase.include?(s) })
48
+ install_completion_for(shell) # completion for one named shell
49
+ elsif /book/i.match?(target) # bookmarks installation
50
+ install_bookmarks
51
+ elsif /conf/i.match?(target) # default config file generation
52
+ install_config
53
+ elsif /safari/i.match?(target) # opt-in Safari FDA setup (macOS only)
54
+ install_safari
55
+ else # unknown argument passed into install
56
+ pexit "Failure: ".red + "unknown installation option (#{target})"
57
+ end
58
+ end
59
+
60
+ # install completion for every supported shell present on this machine, so a
61
+ # zsh user who also drops into bash gets both without running install twice
62
+ def install_completion
63
+ found, missing = SHELLS.partition { |shell| shell_present?(shell) }
64
+
65
+ if found.empty?
66
+ puts "Warning: ".yel + "no supported shell found (#{SHELLS.join(", ")})"
67
+ return
68
+ end
69
+
70
+ found.each { |shell| install_completion_for(shell) }
71
+
72
+ puts "Skip: ".yel + "#{missing.join(", ")} not installed" unless missing.empty?
73
+ end
74
+
75
+ # every shell in SHELLS has an install_completion_<shell>, so adding a fourth
76
+ # means adding the script, the method, and the SHELLS entry - nothing here
77
+ def install_completion_for(shell)
78
+ send("install_completion_#{shell}")
79
+ end
80
+
81
+ def install_completion_zsh
82
+ # check if zsh is even installed for this user - capture3 raises
83
+ # Errno::ENOENT when it is not, same as the backtick this replaces
84
+ begin
85
+ out, _err, _status = Open3.capture3("zsh", "-c", "echo $fpath")
86
+ fpath = out.split(" ")
87
+ rescue
88
+ pexit "Failure: ".red + "zsh is probably not installed, could not find $fpath"
89
+ end
90
+
91
+ # Try user-writable directories first, then system directories
92
+ user_home = home
93
+ writable_dirs = fpath.select do |fp|
94
+ fp.start_with?(user_home) && File.directory?(fp) && File.writable?(fp)
95
+ end
96
+
97
+ # If no user-writable directories, try to create one
98
+ if writable_dirs.empty?
99
+ user_completion_dir = File.join(user_home, ".zsh", "completion")
100
+ begin
101
+ # mkdir_p is happy either way, but saying "Created" every time a
102
+ # developer reinstalls is a small lie about what just happened
103
+ existed = File.directory?(user_completion_dir)
104
+ FileUtils.mkdir_p(user_completion_dir)
105
+ writable_dirs << user_completion_dir
106
+ puts "Created user completion directory: #{user_completion_dir}".yel unless existed
107
+
108
+ # Auto-configure .zshrc if it exists
109
+ zshrc = File.join(user_home, ".zshrc")
110
+ if File.exist?(zshrc)
111
+ # the marker is the directory, not the whole block: a zshrc that
112
+ # already puts it on $fpath is configured, however it spelled the
113
+ # compinit call next to it
114
+ append_once(zshrc, ZSHRC_LINES, marker: ".zsh/completion", label: "~/.zshrc") do
115
+ puts "Run: ".yel + "source ~/.zshrc".cyan + " to activate"
116
+ end
117
+ else
118
+ puts "Add this to your ~/.zshrc: ".yel + "fpath=(~/.zsh/completion $fpath)".cyan
119
+ end
120
+ rescue
121
+ # Couldn't create user dir, try system dirs as fallback
122
+ end
123
+ end
124
+
125
+ # Try writable directories first, then all directories as fallback
126
+ dirs_to_try = writable_dirs + fpath.reject { |fp| writable_dirs.include?(fp) }
127
+
128
+ success = false
129
+ dirs_to_try.each do |fp|
130
+ next unless File.directory?(fp)
131
+
132
+ begin
133
+ # nothing is autoloaded here on purpose: `zsh -c 'autoload -U
134
+ # _booker'` loaded it into a subshell that exited on the next line.
135
+ # clear_zsh_compdump below is what reaches a new shell
136
+ File.write(File.join(fp, "_booker"), completion_script("_booker"))
137
+ puts "Success: ".grn + "installed zsh autocompletion in #{fp}"
138
+ success = true
139
+ break
140
+ rescue
141
+ # Try next directory silently
142
+ end
143
+ end
144
+
145
+ if success
146
+ clear_zsh_compdump
147
+ else
148
+ puts "Warning: ".yel + "Could not install ZSH completion to any directory in $fpath"
149
+ puts "Try manually: ".grn + "mkdir -p ~/.zsh/completion && booker --install zsh"
150
+ end
151
+ end
152
+
153
+ # compinit caches what it found in $fpath, so a shell started against an old
154
+ # dump keeps describing the completion booker just replaced. dropping the
155
+ # cache - zsh rebuilds it on the next start - is how a new script reaches
156
+ # the next shell. nothing helps the shell you ran this from, which autoloaded
157
+ # _booker for the life of the session, hence the unfunction advice
158
+ def clear_zsh_compdump
159
+ base = ENV["ZDOTDIR"] || home
160
+ dumps = Dir.glob(File.join(base, ".zcompdump*"))
161
+ return if dumps.empty?
162
+
163
+ dumps.each do |dump|
164
+ File.delete(dump)
165
+ rescue
166
+ # a dump we cannot remove is not worth failing an install over
167
+ end
168
+
169
+ puts "Refreshed: ".grn + "zsh completion cache (rebuilds on next shell)"
170
+ end
171
+
172
+ # bash-completion (when installed) autoloads from the XDG user directory.
173
+ # Without it there is nothing doing the loading, so fall back to a plain
174
+ # directory plus a source line in ~/.bashrc.
175
+ def install_completion_bash
176
+ autoloaded = bash_completion_present?
177
+ dir = if autoloaded
178
+ File.join(xdg_data_home, "bash-completion", "completions")
179
+ else
180
+ File.join(home, ".bash_completion.d")
181
+ end
182
+
183
+ FileUtils.mkdir_p(dir)
184
+ File.write(File.join(dir, "booker"), completion_script("booker.bash"))
185
+ puts "Success: ".grn + "installed bash completion in #{dir}"
186
+
187
+ # with bash-completion installed there is nothing left to wire up
188
+ unless autoloaded
189
+ append_once(
190
+ File.join(home, ".bashrc"),
191
+ "[ -f ~/.bash_completion.d/booker ] && . ~/.bash_completion.d/booker"
192
+ )
193
+ end
194
+
195
+ puts "Run: ".yel + "source ~/.bashrc".cyan + " to activate"
196
+ rescue => e
197
+ puts "Warning: ".yel + "could not install bash completion (#{e.message})"
198
+ end
199
+
200
+ # fish autoloads anything in its completions directory, so there is no rc
201
+ # file to edit here
202
+ def install_completion_fish
203
+ dir = File.join(xdg_config_home, "fish", "completions")
204
+ FileUtils.mkdir_p(dir)
205
+ File.write(File.join(dir, "booker.fish"), completion_script("booker.fish"))
206
+ puts "Success: ".grn + "installed fish completion in #{dir}"
207
+ puts "Run: ".yel + "exec fish".cyan + " to activate"
208
+ rescue => e
209
+ puts "Warning: ".yel + "could not install fish completion (#{e.message})"
210
+ end
211
+
212
+ # read one of the shipped completion scripts (completions/ sits next to lib/,
213
+ # and is listed in the gemspec so it ships with the gem)
214
+ def completion_script(name)
215
+ File.read(File.join(COMPLETIONS_DIR, name))
216
+ rescue Errno::ENOENT
217
+ pexit "Failure: ".red + "completion script #{name} missing from #{COMPLETIONS_DIR}"
218
+ end
219
+
220
+ # Picker.which, not `sh -c "command -v"`: a fork per shell to answer a PATH
221
+ # question booker already knows how to answer in process
222
+ def shell_present?(shell) = !Picker.which(shell).nil?
223
+
224
+ def bash_completion_present?
225
+ BASH_COMPLETION_MARKERS.any? { |marker| File.exist?(marker) }
226
+ end
227
+
228
+ # append to an rc file once - install is expected to be re-runnable without
229
+ # stacking up duplicates. `marker` is what counts as "already there" when
230
+ # that is narrower than what gets written; `label` is the name shown
231
+ def append_once(rcfile, lines, marker: lines, label: rcfile)
232
+ if File.exist?(rcfile) && File.read(rcfile).include?(marker)
233
+ puts "#{label} already configured".grn
234
+ return
235
+ end
236
+
237
+ File.open(rcfile, "a") do |f|
238
+ f.puts "\n# Booker completion"
239
+ f.puts lines
240
+ end
241
+ puts "Added completion to #{label}".grn
242
+ yield if block_given?
243
+ end
244
+
245
+ # every target that touches the config is about to write it, so the
246
+ # missing-file warning would only tell the user to do what they are doing.
247
+ # its own instance rather than Config.default: that memo is shared with the
248
+ # rest of the run, and whichever caller built it first would decide whether
249
+ # the warning shows for everyone
250
+ def config = @config ||= Config.new(quiet_missing: true)
251
+
252
+ def home
253
+ ENV["HOME"] || "/usr/local"
254
+ end
255
+
256
+ def xdg_data_home
257
+ ENV["XDG_DATA_HOME"] || File.join(home, ".local", "share")
258
+ end
259
+
260
+ def xdg_config_home
261
+ ENV["XDG_CONFIG_HOME"] || File.join(home, ".config")
262
+ end
263
+
264
+ # locate bookmarks files, show the user, write the choice to the config.
265
+ # the search is Sources.discover, the same code auto-detection falls back
266
+ # on, so this offers exactly what booker would find on its own
267
+ def install_bookmarks
268
+ puts "searching for browser bookmarks..."
269
+ begin
270
+ bms = Sources.discover
271
+
272
+ if bms.empty? # no bookmarks found
273
+ puts "Failure: ".red + "bookmarks file could not be found."
274
+ raise
275
+ elsif bms.length == 1
276
+ # Auto-select if only one source found
277
+ selected = bms.first
278
+ puts "Found bookmark source: #{bookmark_type_label(Bookmarks.source_for(selected))} #{selected}".yel
279
+ save_bookmarks(selected, "config file updated with your bookmarks")
280
+ else # have user select a file
281
+ puts "select bookmarks source: "
282
+
283
+ # Offer "ALL" as first option if multiple sources found
284
+ puts "0".grn + " - " + "[ALL SOURCES]".cyan + " (search across all browsers)"
285
+
286
+ bms.each_with_index do |path, i|
287
+ label = bookmark_type_label(Bookmarks.source_for(path), color: true)
288
+ puts (i + 1).to_s.grn + " - " + label + " " + path
289
+ end
290
+
291
+ input = gets
292
+ raise "No input provided" if input.nil?
293
+ selection = input.chomp.to_i
294
+
295
+ if selection == 0
296
+ # User selected "ALL" - save array of all paths
297
+ puts "Selected: ".yel + "All sources (#{bms.length} bookmark files)"
298
+ config.write(:bookmarks, bms)
299
+ puts "Success: ".grn + "config file updated to search all bookmark sources"
300
+ else
301
+ save_bookmarks(bms[selection - 1], "config file updated with your bookmarks")
302
+ end
303
+ end
304
+ rescue => e
305
+ puts e.message
306
+ pexit "Failure: ".red + "could not add bookmarks to config file ~/.booker"
307
+ end
308
+ end
309
+
310
+ def save_bookmarks(selected, message)
311
+ puts "Selected: ".yel + selected
312
+ config.write(:bookmarks, selected)
313
+ puts "Success: ".grn + message
314
+ end
315
+
316
+ # the label and its color come off the browser table, so a fourth browser
317
+ # is a row there rather than two more branches here
318
+ def bookmark_type_label(type, color: false)
319
+ browser = Bookmarks::BROWSERS[type]
320
+ return "[?]" if browser.nil?
321
+
322
+ color ? Colors.paint(browser[:label], browser[:color]) : browser[:label]
323
+ end
324
+
325
+ def install_config
326
+ config.write
327
+ puts "Success: ".grn + "example config file written to ~/.booker"
328
+ rescue
329
+ pexit "Failure: ".red + "could not write example config file to ~/.booker"
330
+ end
331
+
332
+ # Opt-in Safari setup: walks through granting Full Disk Access so booker
333
+ # can read ~/Library/Safari/Bookmarks.plist. Not included in the default
334
+ # --install flow because it requires a TCC permission grant.
335
+ def install_safari
336
+ plist = File.join(home, "Library/Safari/Bookmarks.plist")
337
+ fda_url = "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles"
338
+
339
+ unless RUBY_PLATFORM.include?("darwin")
340
+ puts "Skip: ".yel + "Safari support is macOS-only."
341
+ return
342
+ end
343
+
344
+ unless File.exist?(plist)
345
+ puts "Skip: ".yel + "Safari bookmarks not found at #{plist}"
346
+ puts "Hint: ".grn + "launch Safari at least once, then re-run."
347
+ return
348
+ end
349
+
350
+ if safari_readable?(plist)
351
+ puts "PASS: ".grn + "Safari bookmarks are already readable."
352
+ return
353
+ end
354
+
355
+ puts "Safari stores bookmarks at:"
356
+ puts " #{plist}".cyan
357
+ puts
358
+ puts "That file is protected by macOS TCC. Grant Full Disk Access to one of:"
359
+ puts
360
+ puts " [A] ".cyan + "Your terminal app".yel + " (simplest; inherited by any tool)"
361
+ puts " [B] ".cyan + "/usr/bin/plutil only".yel + " (narrower scope)"
362
+ puts
363
+ print "Pick [A] or [B] (default A): "
364
+ $stdout.flush
365
+ choice = $stdin.gets.to_s.strip.upcase
366
+ choice = "A" if choice.empty?
367
+ pexit "Error: ".red + "invalid choice." unless %w[A B].include?(choice)
368
+
369
+ puts
370
+ puts "Opening the Full Disk Access pane..."
371
+ system("open", fda_url)
372
+ puts
373
+
374
+ puts "In the pane that just opened:"
375
+ if choice == "A"
376
+ puts " 1. Click ".yel + "+".cyan + ", add your terminal app from /Applications"
377
+ puts " 2. Toggle it ".yel + "on".cyan
378
+ puts " 3. ".yel + "Fully quit".cyan + " the terminal (Cmd+Q), reopen it,"
379
+ puts " and re-run ".yel + "booker --install safari".cyan + " to verify."
380
+ else
381
+ puts " 1. Click ".yel + "+".cyan
382
+ puts " 2. Press ".yel + "Cmd+Shift+G".cyan + " (opens 'Go to Folder')".yel
383
+ puts " 3. Type ".yel + "/usr/bin/plutil".cyan + " and press Return".yel
384
+ puts " 4. Click ".yel + "Open".cyan + ", then toggle it ".yel + "on".cyan
385
+ puts
386
+ print "Press Return once you've added plutil and toggled it on... "
387
+ $stdout.flush
388
+ $stdin.gets
389
+
390
+ if safari_readable?(plist)
391
+ puts "PASS: ".grn + "Safari bookmarks are now readable."
392
+ else
393
+ puts "FAIL: ".red + "still can't read the bookmarks file."
394
+ puts "Try fully quitting the terminal (Cmd+Q) and re-running."
395
+ end
396
+ end
397
+ end
398
+
399
+ def safari_readable?(plist)
400
+ system("plutil", "-lint", "-s", plist, out: File::NULL, err: File::NULL)
401
+ end
402
+ end
403
+ end