1 /*
2 ==============================================================================
3
4 This file is part of the JUCE library.
5 Copyright (c) 2020 - Raw Material Software Limited
6
7 JUCE is an open source library subject to commercial or open-source
8 licensing.
9
10 By using JUCE, you agree to the terms of both the JUCE 6 End-User License
11 Agreement and JUCE Privacy Policy (both effective as of the 16th June 2020).
12
13 End User License Agreement: www.juce.com/juce-6-licence
14 Privacy Policy: www.juce.com/juce-privacy-policy
15
16 Or: You may also use this code under the terms of the GPL v3 (see
17 www.gnu.org/licenses).
18
19 JUCE IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER
20 EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE
21 DISCLAIMED.
22
23 ==============================================================================
24 */
25
26 namespace juce
27 {
28
29 class PluginListComponent::TableModel : public TableListBoxModel
30 {
31 public:
TableModel(PluginListComponent & c,KnownPluginList & l)32 TableModel (PluginListComponent& c, KnownPluginList& l) : owner (c), list (l) {}
33
getNumRows()34 int getNumRows() override
35 {
36 return list.getNumTypes() + list.getBlacklistedFiles().size();
37 }
38
paintRowBackground(Graphics & g,int,int,int,bool rowIsSelected)39 void paintRowBackground (Graphics& g, int /*rowNumber*/, int /*width*/, int /*height*/, bool rowIsSelected) override
40 {
41 const auto defaultColour = owner.findColour (ListBox::backgroundColourId);
42 const auto c = rowIsSelected ? defaultColour.interpolatedWith (owner.findColour (ListBox::textColourId), 0.5f)
43 : defaultColour;
44
45 g.fillAll (c);
46 }
47
48 enum
49 {
50 nameCol = 1,
51 typeCol = 2,
52 categoryCol = 3,
53 manufacturerCol = 4,
54 descCol = 5
55 };
56
paintCell(Graphics & g,int row,int columnId,int width,int height,bool)57 void paintCell (Graphics& g, int row, int columnId, int width, int height, bool /*rowIsSelected*/) override
58 {
59 String text;
60 bool isBlacklisted = row >= list.getNumTypes();
61
62 if (isBlacklisted)
63 {
64 if (columnId == nameCol)
65 text = list.getBlacklistedFiles() [row - list.getNumTypes()];
66 else if (columnId == descCol)
67 text = TRANS("Deactivated after failing to initialise correctly");
68 }
69 else
70 {
71 auto desc = list.getTypes()[row];
72
73 switch (columnId)
74 {
75 case nameCol: text = desc.name; break;
76 case typeCol: text = desc.pluginFormatName; break;
77 case categoryCol: text = desc.category.isNotEmpty() ? desc.category : "-"; break;
78 case manufacturerCol: text = desc.manufacturerName; break;
79 case descCol: text = getPluginDescription (desc); break;
80
81 default: jassertfalse; break;
82 }
83 }
84
85 if (text.isNotEmpty())
86 {
87 const auto defaultTextColour = owner.findColour (ListBox::textColourId);
88 g.setColour (isBlacklisted ? Colours::red
89 : columnId == nameCol ? defaultTextColour
90 : defaultTextColour.interpolatedWith (Colours::transparentBlack, 0.3f));
91 g.setFont (Font ((float) height * 0.7f, Font::bold));
92 g.drawFittedText (text, 4, 0, width - 6, height, Justification::centredLeft, 1, 0.9f);
93 }
94 }
95
cellClicked(int rowNumber,int columnId,const juce::MouseEvent & e)96 void cellClicked (int rowNumber, int columnId, const juce::MouseEvent& e) override
97 {
98 TableListBoxModel::cellClicked (rowNumber, columnId, e);
99
100 if (rowNumber >= 0 && rowNumber < getNumRows() && e.mods.isPopupMenu())
101 owner.createMenuForRow (rowNumber).showMenuAsync (PopupMenu::Options().withDeletionCheck (owner));
102 }
103
deleteKeyPressed(int)104 void deleteKeyPressed (int) override
105 {
106 owner.removeSelectedPlugins();
107 }
108
sortOrderChanged(int newSortColumnId,bool isForwards)109 void sortOrderChanged (int newSortColumnId, bool isForwards) override
110 {
111 switch (newSortColumnId)
112 {
113 case nameCol: list.sort (KnownPluginList::sortAlphabetically, isForwards); break;
114 case typeCol: list.sort (KnownPluginList::sortByFormat, isForwards); break;
115 case categoryCol: list.sort (KnownPluginList::sortByCategory, isForwards); break;
116 case manufacturerCol: list.sort (KnownPluginList::sortByManufacturer, isForwards); break;
117 case descCol: break;
118
119 default: jassertfalse; break;
120 }
121 }
122
getPluginDescription(const PluginDescription & desc)123 static String getPluginDescription (const PluginDescription& desc)
124 {
125 StringArray items;
126
127 if (desc.descriptiveName != desc.name)
128 items.add (desc.descriptiveName);
129
130 items.add (desc.version);
131
132 items.removeEmptyStrings();
133 return items.joinIntoString (" - ");
134 }
135
136 PluginListComponent& owner;
137 KnownPluginList& list;
138
139 JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (TableModel)
140 };
141
142 //==============================================================================
PluginListComponent(AudioPluginFormatManager & manager,KnownPluginList & listToEdit,const File & deadMansPedal,PropertiesFile * const props,bool allowPluginsWhichRequireAsynchronousInstantiation)143 PluginListComponent::PluginListComponent (AudioPluginFormatManager& manager, KnownPluginList& listToEdit,
144 const File& deadMansPedal, PropertiesFile* const props,
145 bool allowPluginsWhichRequireAsynchronousInstantiation)
146 : formatManager (manager),
147 list (listToEdit),
148 deadMansPedalFile (deadMansPedal),
149 optionsButton ("Options..."),
150 propertiesToUse (props),
151 allowAsync (allowPluginsWhichRequireAsynchronousInstantiation),
152 numThreads (allowAsync ? 1 : 0)
153 {
154 tableModel.reset (new TableModel (*this, listToEdit));
155
156 TableHeaderComponent& header = table.getHeader();
157
158 header.addColumn (TRANS("Name"), TableModel::nameCol, 200, 100, 700, TableHeaderComponent::defaultFlags | TableHeaderComponent::sortedForwards);
159 header.addColumn (TRANS("Format"), TableModel::typeCol, 80, 80, 80, TableHeaderComponent::notResizable);
160 header.addColumn (TRANS("Category"), TableModel::categoryCol, 100, 100, 200);
161 header.addColumn (TRANS("Manufacturer"), TableModel::manufacturerCol, 200, 100, 300);
162 header.addColumn (TRANS("Description"), TableModel::descCol, 300, 100, 500, TableHeaderComponent::notSortable);
163
164 table.setHeaderHeight (22);
165 table.setRowHeight (20);
166 table.setModel (tableModel.get());
167 table.setMultipleSelectionEnabled (true);
168 addAndMakeVisible (table);
169
170 addAndMakeVisible (optionsButton);
171 optionsButton.onClick = [this]
172 {
173 createOptionsMenu().showMenuAsync (PopupMenu::Options()
174 .withDeletionCheck (*this)
175 .withTargetComponent (optionsButton));
176 };
177
178 optionsButton.setTriggeredOnMouseDown (true);
179
180 setSize (400, 600);
181 list.addChangeListener (this);
182 updateList();
183 table.getHeader().reSortTable();
184
185 PluginDirectoryScanner::applyBlacklistingsFromDeadMansPedal (list, deadMansPedalFile);
186 deadMansPedalFile.deleteFile();
187 }
188
~PluginListComponent()189 PluginListComponent::~PluginListComponent()
190 {
191 list.removeChangeListener (this);
192 }
193
setOptionsButtonText(const String & newText)194 void PluginListComponent::setOptionsButtonText (const String& newText)
195 {
196 optionsButton.setButtonText (newText);
197 resized();
198 }
199
setScanDialogText(const String & title,const String & content)200 void PluginListComponent::setScanDialogText (const String& title, const String& content)
201 {
202 dialogTitle = title;
203 dialogText = content;
204 }
205
setNumberOfThreadsForScanning(int num)206 void PluginListComponent::setNumberOfThreadsForScanning (int num)
207 {
208 numThreads = num;
209 }
210
resized()211 void PluginListComponent::resized()
212 {
213 auto r = getLocalBounds().reduced (2);
214
215 if (optionsButton.isVisible())
216 {
217 optionsButton.setBounds (r.removeFromBottom (24));
218 optionsButton.changeWidthToFitText (24);
219 r.removeFromBottom (3);
220 }
221
222 table.setBounds (r);
223 }
224
changeListenerCallback(ChangeBroadcaster *)225 void PluginListComponent::changeListenerCallback (ChangeBroadcaster*)
226 {
227 table.getHeader().reSortTable();
228 updateList();
229 }
230
updateList()231 void PluginListComponent::updateList()
232 {
233 table.updateContent();
234 table.repaint();
235 }
236
removeSelectedPlugins()237 void PluginListComponent::removeSelectedPlugins()
238 {
239 auto selected = table.getSelectedRows();
240
241 for (int i = table.getNumRows(); --i >= 0;)
242 if (selected.contains (i))
243 removePluginItem (i);
244 }
245
setTableModel(TableListBoxModel * model)246 void PluginListComponent::setTableModel (TableListBoxModel* model)
247 {
248 table.setModel (nullptr);
249 tableModel.reset (model);
250 table.setModel (tableModel.get());
251
252 table.getHeader().reSortTable();
253 table.updateContent();
254 table.repaint();
255 }
256
canShowFolderForPlugin(KnownPluginList & list,int index)257 static bool canShowFolderForPlugin (KnownPluginList& list, int index)
258 {
259 return File::createFileWithoutCheckingPath (list.getTypes()[index].fileOrIdentifier).exists();
260 }
261
showFolderForPlugin(KnownPluginList & list,int index)262 static void showFolderForPlugin (KnownPluginList& list, int index)
263 {
264 if (canShowFolderForPlugin (list, index))
265 File (list.getTypes()[index].fileOrIdentifier).getParentDirectory().startAsProcess();
266 }
267
removeMissingPlugins()268 void PluginListComponent::removeMissingPlugins()
269 {
270 auto types = list.getTypes();
271
272 for (int i = types.size(); --i >= 0;)
273 {
274 auto type = types.getUnchecked (i);
275
276 if (! formatManager.doesPluginStillExist (type))
277 list.removeType (type);
278 }
279 }
280
removePluginItem(int index)281 void PluginListComponent::removePluginItem (int index)
282 {
283 if (index < list.getNumTypes())
284 list.removeType (list.getTypes()[index]);
285 else
286 list.removeFromBlacklist (list.getBlacklistedFiles() [index - list.getNumTypes()]);
287 }
288
createOptionsMenu()289 PopupMenu PluginListComponent::createOptionsMenu()
290 {
291 PopupMenu menu;
292 menu.addItem (PopupMenu::Item (TRANS("Clear list"))
293 .setAction ([this] { list.clear(); }));
294
295 menu.addSeparator();
296
297 for (auto format : formatManager.getFormats())
298 if (format->canScanForPlugins())
299 menu.addItem (PopupMenu::Item ("Remove all " + format->getName() + " plug-ins")
300 .setEnabled (! list.getTypesForFormat (*format).isEmpty())
301 .setAction ([this, format]
302 {
303 for (auto& pd : list.getTypesForFormat (*format))
304 list.removeType (pd);
305 }));
306
307 menu.addSeparator();
308
309 menu.addItem (PopupMenu::Item (TRANS("Remove selected plug-in from list"))
310 .setEnabled (table.getNumSelectedRows() > 0)
311 .setAction ([this] { removeSelectedPlugins(); }));
312
313 menu.addItem (PopupMenu::Item (TRANS("Remove any plug-ins whose files no longer exist"))
314 .setAction ([this] { removeMissingPlugins(); }));
315
316 menu.addSeparator();
317
318 auto selectedRow = table.getSelectedRow();
319
320 menu.addItem (PopupMenu::Item (TRANS("Show folder containing selected plug-in"))
321 .setEnabled (canShowFolderForPlugin (list, selectedRow))
322 .setAction ([this, selectedRow] { showFolderForPlugin (list, selectedRow); }));
323
324 menu.addSeparator();
325
326 for (auto format : formatManager.getFormats())
327 if (format->canScanForPlugins())
328 menu.addItem (PopupMenu::Item ("Scan for new or updated " + format->getName() + " plug-ins")
329 .setAction ([this, format] { scanFor (*format); }));
330
331 return menu;
332 }
333
createMenuForRow(int rowNumber)334 PopupMenu PluginListComponent::createMenuForRow (int rowNumber)
335 {
336 PopupMenu menu;
337
338 if (rowNumber >= 0 && rowNumber < tableModel->getNumRows())
339 {
340 menu.addItem (PopupMenu::Item (TRANS("Remove plug-in from list"))
341 .setAction ([this, rowNumber] { removePluginItem (rowNumber); }));
342
343 menu.addItem (PopupMenu::Item (TRANS("Show folder containing plug-in"))
344 .setEnabled (canShowFolderForPlugin (list, rowNumber))
345 .setAction ([this, rowNumber] { showFolderForPlugin (list, rowNumber); }));
346 }
347
348 return menu;
349 }
350
isInterestedInFileDrag(const StringArray &)351 bool PluginListComponent::isInterestedInFileDrag (const StringArray& /*files*/)
352 {
353 return true;
354 }
355
filesDropped(const StringArray & files,int,int)356 void PluginListComponent::filesDropped (const StringArray& files, int, int)
357 {
358 OwnedArray<PluginDescription> typesFound;
359 list.scanAndAddDragAndDroppedFiles (formatManager, files, typesFound);
360 }
361
getLastSearchPath(PropertiesFile & properties,AudioPluginFormat & format)362 FileSearchPath PluginListComponent::getLastSearchPath (PropertiesFile& properties, AudioPluginFormat& format)
363 {
364 auto key = "lastPluginScanPath_" + format.getName();
365
366 if (properties.containsKey (key) && properties.getValue (key, {}).trim().isEmpty())
367 properties.removeValue (key);
368
369 return FileSearchPath (properties.getValue (key, format.getDefaultLocationsToSearch().toString()));
370 }
371
setLastSearchPath(PropertiesFile & properties,AudioPluginFormat & format,const FileSearchPath & newPath)372 void PluginListComponent::setLastSearchPath (PropertiesFile& properties, AudioPluginFormat& format,
373 const FileSearchPath& newPath)
374 {
375 auto key = "lastPluginScanPath_" + format.getName();
376
377 if (newPath.getNumPaths() == 0)
378 properties.removeValue (key);
379 else
380 properties.setValue (key, newPath.toString());
381 }
382
383 //==============================================================================
384 class PluginListComponent::Scanner : private Timer
385 {
386 public:
Scanner(PluginListComponent & plc,AudioPluginFormat & format,const StringArray & filesOrIdentifiers,PropertiesFile * properties,bool allowPluginsWhichRequireAsynchronousInstantiation,int threads,const String & title,const String & text)387 Scanner (PluginListComponent& plc, AudioPluginFormat& format, const StringArray& filesOrIdentifiers,
388 PropertiesFile* properties, bool allowPluginsWhichRequireAsynchronousInstantiation, int threads,
389 const String& title, const String& text)
390 : owner (plc), formatToScan (format), filesOrIdentifiersToScan (filesOrIdentifiers), propertiesToUse (properties),
391 pathChooserWindow (TRANS("Select folders to scan..."), String(), AlertWindow::NoIcon),
392 progressWindow (title, text, AlertWindow::NoIcon),
393 numThreads (threads), allowAsync (allowPluginsWhichRequireAsynchronousInstantiation)
394 {
395 FileSearchPath path (formatToScan.getDefaultLocationsToSearch());
396
397 // You need to use at least one thread when scanning plug-ins asynchronously
398 jassert (! allowAsync || (numThreads > 0));
399
400 // If the filesOrIdentifiersToScan argument isn't empty, we should only scan these
401 // If the path is empty, then paths aren't used for this format.
402 if (filesOrIdentifiersToScan.isEmpty() && path.getNumPaths() > 0)
403 {
404 #if ! JUCE_IOS
405 if (propertiesToUse != nullptr)
406 path = getLastSearchPath (*propertiesToUse, formatToScan);
407 #endif
408
409 pathList.setSize (500, 300);
410 pathList.setPath (path);
411
412 pathChooserWindow.addCustomComponent (&pathList);
413 pathChooserWindow.addButton (TRANS("Scan"), 1, KeyPress (KeyPress::returnKey));
414 pathChooserWindow.addButton (TRANS("Cancel"), 0, KeyPress (KeyPress::escapeKey));
415
416 pathChooserWindow.enterModalState (true,
417 ModalCallbackFunction::forComponent (startScanCallback,
418 &pathChooserWindow, this),
419 false);
420 }
421 else
422 {
423 startScan();
424 }
425 }
426
~Scanner()427 ~Scanner() override
428 {
429 if (pool != nullptr)
430 {
431 pool->removeAllJobs (true, 60000);
432 pool.reset();
433 }
434 }
435
436 private:
437 PluginListComponent& owner;
438 AudioPluginFormat& formatToScan;
439 StringArray filesOrIdentifiersToScan;
440 PropertiesFile* propertiesToUse;
441 std::unique_ptr<PluginDirectoryScanner> scanner;
442 AlertWindow pathChooserWindow, progressWindow;
443 FileSearchPathListComponent pathList;
444 String pluginBeingScanned;
445 double progress = 0;
446 int numThreads;
447 bool allowAsync, finished = false, timerReentrancyCheck = false;
448 std::unique_ptr<ThreadPool> pool;
449
startScanCallback(int result,AlertWindow * alert,Scanner * scanner)450 static void startScanCallback (int result, AlertWindow* alert, Scanner* scanner)
451 {
452 if (alert != nullptr && scanner != nullptr)
453 {
454 if (result != 0)
455 scanner->warnUserAboutStupidPaths();
456 else
457 scanner->finishedScan();
458 }
459 }
460
461 // Try to dissuade people from to scanning their entire C: drive, or other system folders.
warnUserAboutStupidPaths()462 void warnUserAboutStupidPaths()
463 {
464 for (int i = 0; i < pathList.getPath().getNumPaths(); ++i)
465 {
466 auto f = pathList.getPath()[i];
467
468 if (isStupidPath (f))
469 {
470 AlertWindow::showOkCancelBox (AlertWindow::WarningIcon,
471 TRANS("Plugin Scanning"),
472 TRANS("If you choose to scan folders that contain non-plugin files, "
473 "then scanning may take a long time, and can cause crashes when "
474 "attempting to load unsuitable files.")
475 + newLine
476 + TRANS ("Are you sure you want to scan the folder \"XYZ\"?")
477 .replace ("XYZ", f.getFullPathName()),
478 TRANS ("Scan"),
479 String(),
480 nullptr,
481 ModalCallbackFunction::create (warnAboutStupidPathsCallback, this));
482 return;
483 }
484 }
485
486 startScan();
487 }
488
isStupidPath(const File & f)489 static bool isStupidPath (const File& f)
490 {
491 Array<File> roots;
492 File::findFileSystemRoots (roots);
493
494 if (roots.contains (f))
495 return true;
496
497 File::SpecialLocationType pathsThatWouldBeStupidToScan[]
498 = { File::globalApplicationsDirectory,
499 File::userHomeDirectory,
500 File::userDocumentsDirectory,
501 File::userDesktopDirectory,
502 File::tempDirectory,
503 File::userMusicDirectory,
504 File::userMoviesDirectory,
505 File::userPicturesDirectory };
506
507 for (auto location : pathsThatWouldBeStupidToScan)
508 {
509 auto sillyFolder = File::getSpecialLocation (location);
510
511 if (f == sillyFolder || sillyFolder.isAChildOf (f))
512 return true;
513 }
514
515 return false;
516 }
517
warnAboutStupidPathsCallback(int result,Scanner * scanner)518 static void warnAboutStupidPathsCallback (int result, Scanner* scanner)
519 {
520 if (result != 0)
521 scanner->startScan();
522 else
523 scanner->finishedScan();
524 }
525
startScan()526 void startScan()
527 {
528 pathChooserWindow.setVisible (false);
529
530 scanner.reset (new PluginDirectoryScanner (owner.list, formatToScan, pathList.getPath(),
531 true, owner.deadMansPedalFile, allowAsync));
532
533 if (! filesOrIdentifiersToScan.isEmpty())
534 {
535 scanner->setFilesOrIdentifiersToScan (filesOrIdentifiersToScan);
536 }
537 else if (propertiesToUse != nullptr)
538 {
539 setLastSearchPath (*propertiesToUse, formatToScan, pathList.getPath());
540 propertiesToUse->saveIfNeeded();
541 }
542
543 progressWindow.addButton (TRANS("Cancel"), 0, KeyPress (KeyPress::escapeKey));
544 progressWindow.addProgressBarComponent (progress);
545 progressWindow.enterModalState();
546
547 if (numThreads > 0)
548 {
549 pool.reset (new ThreadPool (numThreads));
550
551 for (int i = numThreads; --i >= 0;)
552 pool->addJob (new ScanJob (*this), true);
553 }
554
555 startTimer (20);
556 }
557
finishedScan()558 void finishedScan()
559 {
560 owner.scanFinished (scanner != nullptr ? scanner->getFailedFiles()
561 : StringArray());
562 }
563
timerCallback()564 void timerCallback() override
565 {
566 if (timerReentrancyCheck)
567 return;
568
569 if (pool == nullptr)
570 {
571 const ScopedValueSetter<bool> setter (timerReentrancyCheck, true);
572
573 if (doNextScan())
574 startTimer (20);
575 }
576
577 if (! progressWindow.isCurrentlyModal())
578 finished = true;
579
580 if (finished)
581 finishedScan();
582 else
583 progressWindow.setMessage (TRANS("Testing") + ":\n\n" + pluginBeingScanned);
584 }
585
doNextScan()586 bool doNextScan()
587 {
588 if (scanner->scanNextFile (true, pluginBeingScanned))
589 {
590 progress = scanner->getProgress();
591 return true;
592 }
593
594 finished = true;
595 return false;
596 }
597
598 struct ScanJob : public ThreadPoolJob
599 {
ScanJobjuce::PluginListComponent::Scanner::ScanJob600 ScanJob (Scanner& s) : ThreadPoolJob ("pluginscan"), scanner (s) {}
601
runJobjuce::PluginListComponent::Scanner::ScanJob602 JobStatus runJob()
603 {
604 while (scanner.doNextScan() && ! shouldExit())
605 {}
606
607 return jobHasFinished;
608 }
609
610 Scanner& scanner;
611
612 JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (ScanJob)
613 };
614
615 JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (Scanner)
616 };
617
scanFor(AudioPluginFormat & format)618 void PluginListComponent::scanFor (AudioPluginFormat& format)
619 {
620 scanFor (format, StringArray());
621 }
622
scanFor(AudioPluginFormat & format,const StringArray & filesOrIdentifiersToScan)623 void PluginListComponent::scanFor (AudioPluginFormat& format, const StringArray& filesOrIdentifiersToScan)
624 {
625 currentScanner.reset (new Scanner (*this, format, filesOrIdentifiersToScan, propertiesToUse, allowAsync, numThreads,
626 dialogTitle.isNotEmpty() ? dialogTitle : TRANS("Scanning for plug-ins..."),
627 dialogText.isNotEmpty() ? dialogText : TRANS("Searching for all possible plug-in files...")));
628 }
629
isScanning() const630 bool PluginListComponent::isScanning() const noexcept
631 {
632 return currentScanner != nullptr;
633 }
634
scanFinished(const StringArray & failedFiles)635 void PluginListComponent::scanFinished (const StringArray& failedFiles)
636 {
637 StringArray shortNames;
638
639 for (auto& f : failedFiles)
640 shortNames.add (File::createFileWithoutCheckingPath (f).getFileName());
641
642 currentScanner.reset(); // mustn't delete this before using the failed files array
643
644 if (shortNames.size() > 0)
645 AlertWindow::showMessageBoxAsync (AlertWindow::InfoIcon,
646 TRANS("Scan complete"),
647 TRANS("Note that the following files appeared to be plugin files, but failed to load correctly")
648 + ":\n\n"
649 + shortNames.joinIntoString (", "));
650 }
651
652 } // namespace juce
653