1 /*
2     SPDX-FileCopyrightText: 2003-2007 Fredrik Höglund <fredrik@kde.org>
3     SPDX-FileCopyrightText: 2019 Benjamin Port <benjamin.port@enioka.com>
4 
5     SPDX-License-Identifier: GPL-2.0-or-later
6 */
7 
8 #include <config-X11.h>
9 
10 #include "cursorthemedata.h"
11 #include "kcmcursortheme.h"
12 
13 #include "../kcms-common_p.h"
14 #include "krdb.h"
15 
16 #include "xcursor/cursortheme.h"
17 #include "xcursor/previewwidget.h"
18 #include "xcursor/sortproxymodel.h"
19 #include "xcursor/themeapplicator.h"
20 #include "xcursor/thememodel.h"
21 
22 #include <KAboutData>
23 #include <KIO/CopyJob>
24 #include <KIO/DeleteJob>
25 #include <KIO/Job>
26 #include <KIO/JobUiDelegate>
27 #include <KLocalizedString>
28 #include <KMessageBox>
29 #include <KPluginFactory>
30 #include <KTar>
31 #include <KUrlRequesterDialog>
32 
33 #include <QStandardItemModel>
34 #include <QX11Info>
35 
36 #include <X11/Xcursor/Xcursor.h>
37 #include <X11/Xlib.h>
38 
39 #include <updatelaunchenvjob.h>
40 
41 #include "cursorthemesettings.h"
42 
43 #ifdef HAVE_XFIXES
44 #include <X11/extensions/Xfixes.h>
45 #endif
46 
47 K_PLUGIN_FACTORY_WITH_JSON(CursorThemeConfigFactory, "kcm_cursortheme.json", registerPlugin<CursorThemeConfig>(); registerPlugin<CursorThemeData>();)
48 
CursorThemeConfig(QObject * parent,const QVariantList & args)49 CursorThemeConfig::CursorThemeConfig(QObject *parent, const QVariantList &args)
50     : KQuickAddons::ManagedConfigModule(parent, args)
51     , m_data(new CursorThemeData(this))
52     , m_canInstall(true)
53     , m_canResize(true)
54     , m_canConfigure(true)
55 {
56     m_preferredSize = cursorThemeSettings()->cursorSize();
57     connect(cursorThemeSettings(), &CursorThemeSettings::cursorThemeChanged, this, &CursorThemeConfig::updateSizeComboBox);
58     qmlRegisterType<PreviewWidget>("org.kde.private.kcm_cursortheme", 1, 0, "PreviewWidget");
59     qmlRegisterAnonymousType<SortProxyModel>("SortProxyModel",1);
60     qmlRegisterAnonymousType<CursorThemeSettings>("CursorThemeSettings",1);
61     KAboutData *aboutData = new KAboutData(QStringLiteral("kcm_cursortheme"),
62                                            i18n("Cursors"),
63                                            QStringLiteral("1.0"),
64                                            QString(),
65                                            KAboutLicense::GPL,
66                                            i18n("(c) 2003-2007 Fredrik Höglund"));
67     aboutData->addAuthor(i18n("Fredrik Höglund"));
68     aboutData->addAuthor(i18n("Marco Martin"));
69     setAboutData(aboutData);
70 
71     m_themeModel = new CursorThemeModel(this);
72 
73     m_themeProxyModel = new SortProxyModel(this);
74     m_themeProxyModel->setSourceModel(m_themeModel);
75     // sort ordering is already case-insensitive; match that for filtering too
76     m_themeProxyModel->setFilterCaseSensitivity(Qt::CaseInsensitive);
77     m_themeProxyModel->sort(NameColumn, Qt::AscendingOrder);
78 
79     m_sizesModel = new QStandardItemModel(this);
80 
81     // Disable the install button if we can't install new themes to ~/.icons,
82     // or Xcursor isn't set up to look for cursor themes there.
83     if (!m_themeModel->searchPaths().contains(QDir::homePath() + "/.icons") || !iconsIsWritable()) {
84         setCanInstall(false);
85     }
86 
87     connect(m_themeModel, &QAbstractItemModel::dataChanged, this, &CursorThemeConfig::settingsChanged);
88     connect(m_themeModel, &QAbstractItemModel::dataChanged, this, [this](const QModelIndex &start, const QModelIndex &end, const QVector<int> &roles) {
89         const QModelIndex currentThemeIndex = m_themeModel->findIndex(cursorThemeSettings()->cursorTheme());
90         if (roles.contains(CursorTheme::PendingDeletionRole) && currentThemeIndex.data(CursorTheme::PendingDeletionRole) == true
91             && start.row() <= currentThemeIndex.row() && currentThemeIndex.row() <= end.row()) {
92             cursorThemeSettings()->setCursorTheme(m_themeModel->theme(m_themeModel->defaultIndex())->name());
93         }
94     });
95 }
96 
~CursorThemeConfig()97 CursorThemeConfig::~CursorThemeConfig()
98 {
99 }
100 
cursorThemeSettings() const101 CursorThemeSettings *CursorThemeConfig::cursorThemeSettings() const
102 {
103     return m_data->settings();
104 }
105 
setCanInstall(bool can)106 void CursorThemeConfig::setCanInstall(bool can)
107 {
108     if (m_canInstall == can) {
109         return;
110     }
111 
112     m_canInstall = can;
113     emit canInstallChanged();
114 }
115 
canInstall() const116 bool CursorThemeConfig::canInstall() const
117 {
118     return m_canInstall;
119 }
120 
setCanResize(bool can)121 void CursorThemeConfig::setCanResize(bool can)
122 {
123     if (m_canResize == can) {
124         return;
125     }
126 
127     m_canResize = can;
128     emit canResizeChanged();
129 }
130 
canResize() const131 bool CursorThemeConfig::canResize() const
132 {
133     return m_canResize;
134 }
135 
setCanConfigure(bool can)136 void CursorThemeConfig::setCanConfigure(bool can)
137 {
138     if (m_canConfigure == can) {
139         return;
140     }
141 
142     m_canConfigure = can;
143     emit canConfigureChanged();
144 }
145 
preferredSize() const146 int CursorThemeConfig::preferredSize() const
147 {
148     return m_preferredSize;
149 }
150 
setPreferredSize(int size)151 void CursorThemeConfig::setPreferredSize(int size)
152 {
153     if (m_preferredSize == size) {
154         return;
155     }
156     m_preferredSize = size;
157     emit preferredSizeChanged();
158 }
159 
canConfigure() const160 bool CursorThemeConfig::canConfigure() const
161 {
162     return m_canConfigure;
163 }
164 
downloadingFile() const165 bool CursorThemeConfig::downloadingFile() const
166 {
167     return m_tempCopyJob;
168 }
169 
cursorsModel()170 QAbstractItemModel *CursorThemeConfig::cursorsModel()
171 {
172     return m_themeProxyModel;
173 }
174 
sizesModel()175 QAbstractItemModel *CursorThemeConfig::sizesModel()
176 {
177     return m_sizesModel;
178 }
179 
iconsIsWritable() const180 bool CursorThemeConfig::iconsIsWritable() const
181 {
182     const QFileInfo icons = QFileInfo(QDir::homePath() + "/.icons");
183     const QFileInfo home = QFileInfo(QDir::homePath());
184 
185     return ((icons.exists() && icons.isDir() && icons.isWritable()) || (!icons.exists() && home.isWritable()));
186 }
187 
updateSizeComboBox()188 void CursorThemeConfig::updateSizeComboBox()
189 {
190     // clear the combo box
191     m_sizesModel->clear();
192 
193     // refill the combo box and adopt its icon size
194     int row = cursorThemeIndex(cursorThemeSettings()->cursorTheme());
195     QModelIndex selected = m_themeProxyModel->index(row, 0);
196     int maxIconWidth = 0;
197     int maxIconHeight = 0;
198     if (selected.isValid()) {
199         const CursorTheme *theme = m_themeProxyModel->theme(selected);
200         const QList<int> sizes = theme->availableSizes();
201         // only refill the combobox if there is more that 1 size
202         if (sizes.size() > 1) {
203             int i;
204             QList<int> comboBoxList;
205             QPixmap m_pixmap;
206 
207             // insert the items
208             m_pixmap = theme->createIcon(0);
209             if (m_pixmap.width() > maxIconWidth) {
210                 maxIconWidth = m_pixmap.width();
211             }
212             if (m_pixmap.height() > maxIconHeight) {
213                 maxIconHeight = m_pixmap.height();
214             }
215 
216             foreach (i, sizes) {
217                 m_pixmap = theme->createIcon(i);
218                 if (m_pixmap.width() > maxIconWidth) {
219                     maxIconWidth = m_pixmap.width();
220                 }
221                 if (m_pixmap.height() > maxIconHeight) {
222                     maxIconHeight = m_pixmap.height();
223                 }
224                 QStandardItem *item = new QStandardItem(QIcon(m_pixmap), QString::number(i));
225                 item->setData(i);
226                 m_sizesModel->appendRow(item);
227                 comboBoxList << i;
228             }
229 
230             // select an item
231             int size = m_preferredSize;
232             int selectItem = comboBoxList.indexOf(size);
233 
234             // cursor size not available for this theme
235             if (selectItem < 0) {
236                 /* Search the value next to cursor size. The first entry (0)
237                    is ignored. (If cursor size would have been 0, then we
238                    would had found it yet. As cursor size is not 0, we won't
239                    default to "automatic size".)*/
240                 int j;
241                 int distance;
242                 int smallestDistance;
243                 selectItem = 1;
244                 j = comboBoxList.value(selectItem);
245                 size = j;
246                 smallestDistance = qAbs(m_preferredSize - j);
247                 for (int i = 2; i < comboBoxList.size(); ++i) {
248                     j = comboBoxList.value(i);
249                     distance = qAbs(m_preferredSize - j);
250                     if (distance < smallestDistance || (distance == smallestDistance && j > m_preferredSize)) {
251                         smallestDistance = distance;
252                         selectItem = i;
253                         size = j;
254                     }
255                 }
256             }
257             cursorThemeSettings()->setCursorSize(size);
258         }
259     }
260 
261     // enable or disable the combobox
262     if (cursorThemeSettings()->isImmutable("cursorSize")) {
263         setCanResize(false);
264     } else {
265         setCanResize(m_sizesModel->rowCount() > 0);
266     }
267     // We need to emit a cursorSizeChanged in all case to refresh UI
268     emit cursorThemeSettings()->cursorSizeChanged();
269 }
270 
cursorSizeIndex(int cursorSize) const271 int CursorThemeConfig::cursorSizeIndex(int cursorSize) const
272 {
273     if (m_sizesModel->rowCount() > 0) {
274         const auto items = m_sizesModel->findItems(QString::number(cursorSize));
275         if (items.count() == 1) {
276             return items.first()->row();
277         }
278     }
279     return -1;
280 }
281 
cursorSizeFromIndex(int index)282 int CursorThemeConfig::cursorSizeFromIndex(int index)
283 {
284     Q_ASSERT(index < m_sizesModel->rowCount() && index >= 0);
285 
286     return m_sizesModel->item(index)->data().toInt();
287 }
288 
cursorThemeIndex(const QString & cursorTheme) const289 int CursorThemeConfig::cursorThemeIndex(const QString &cursorTheme) const
290 {
291     auto results = m_themeProxyModel->findIndex(cursorTheme);
292     return results.row();
293 }
294 
cursorThemeFromIndex(int index) const295 QString CursorThemeConfig::cursorThemeFromIndex(int index) const
296 {
297     QModelIndex idx = m_themeProxyModel->index(index, 0);
298     return m_themeProxyModel->theme(idx)->name();
299 }
300 
save()301 void CursorThemeConfig::save()
302 {
303     ManagedConfigModule::save();
304     setPreferredSize(cursorThemeSettings()->cursorSize());
305 
306     int row = cursorThemeIndex(cursorThemeSettings()->cursorTheme());
307     QModelIndex selected = m_themeProxyModel->index(row, 0);
308     const CursorTheme *theme = selected.isValid() ? m_themeProxyModel->theme(selected) : nullptr;
309 
310     if (!applyTheme(theme, cursorThemeSettings()->cursorSize())) {
311         emit showInfoMessage(i18n("You have to restart the Plasma session for these changes to take effect."));
312     }
313     removeThemes();
314 
315     notifyKcmChange(GlobalChangeType::CursorChanged);
316 }
317 
load()318 void CursorThemeConfig::load()
319 {
320     ManagedConfigModule::load();
321     setPreferredSize(cursorThemeSettings()->cursorSize());
322 
323     // Disable the listview and the buttons if we're in kiosk mode
324     if (cursorThemeSettings()->isImmutable(QStringLiteral("cursorTheme"))) {
325         setCanConfigure(false);
326         setCanInstall(false);
327     }
328 
329     updateSizeComboBox(); // This handles also the kiosk mode
330 
331     setNeedsSave(false);
332 }
333 
defaults()334 void CursorThemeConfig::defaults()
335 {
336     ManagedConfigModule::defaults();
337     m_preferredSize = cursorThemeSettings()->cursorSize();
338 }
339 
isSaveNeeded() const340 bool CursorThemeConfig::isSaveNeeded() const
341 {
342     return !m_themeModel->match(m_themeModel->index(0, 0), CursorTheme::PendingDeletionRole, true).isEmpty();
343 }
344 
ghnsEntryChanged(KNSCore::EntryWrapper * entry)345 void CursorThemeConfig::ghnsEntryChanged(KNSCore::EntryWrapper *entry)
346 {
347     if (entry->entry().status() == KNS3::Entry::Deleted) {
348         for (const QString &deleted : entry->entry().uninstalledFiles()) {
349             QVector<QStringRef> list = deleted.splitRef(QLatin1Char('/'));
350             if (list.last() == QLatin1Char('*')) {
351                 list.takeLast();
352             }
353             QModelIndex idx = m_themeModel->findIndex(list.last().toString());
354             if (idx.isValid()) {
355                 m_themeModel->removeTheme(idx);
356             }
357         }
358     } else if (entry->entry().status() == KNS3::Entry::Installed) {
359         for (const QString &created : entry->entry().installedFiles()) {
360             QStringList list = created.split(QLatin1Char('/'));
361             if (list.last() == QLatin1Char('*')) {
362                 list.takeLast();
363             }
364             // Because we sometimes get some extra slashes in the installed files list
365             list.removeAll({});
366             // Because we'll also get the containing folder, if it was not already there
367             // we need to ignore it.
368             if (list.last() == QLatin1String(".icons")) {
369                 continue;
370             }
371             m_themeModel->addTheme(list.join(QLatin1Char('/')));
372         }
373     }
374 }
375 
installThemeFromFile(const QUrl & url)376 void CursorThemeConfig::installThemeFromFile(const QUrl &url)
377 {
378     if (url.isLocalFile()) {
379         installThemeFile(url.toLocalFile());
380         return;
381     }
382 
383     if (m_tempCopyJob) {
384         return;
385     }
386 
387     m_tempInstallFile.reset(new QTemporaryFile());
388     if (!m_tempInstallFile->open()) {
389         emit showErrorMessage(i18n("Unable to create a temporary file."));
390         m_tempInstallFile.reset();
391         return;
392     }
393 
394     m_tempCopyJob = KIO::file_copy(url, QUrl::fromLocalFile(m_tempInstallFile->fileName()), -1, KIO::Overwrite);
395     m_tempCopyJob->uiDelegate()->setAutoErrorHandlingEnabled(true);
396     emit downloadingFileChanged();
397 
398     connect(m_tempCopyJob, &KIO::FileCopyJob::result, this, [this, url](KJob *job) {
399         if (job->error() != KJob::NoError) {
400             emit showErrorMessage(i18n("Unable to download the icon theme archive: %1", job->errorText()));
401             return;
402         }
403 
404         installThemeFile(m_tempInstallFile->fileName());
405         m_tempInstallFile.reset();
406     });
407     connect(m_tempCopyJob, &QObject::destroyed, this, &CursorThemeConfig::downloadingFileChanged);
408 }
409 
installThemeFile(const QString & path)410 void CursorThemeConfig::installThemeFile(const QString &path)
411 {
412     KTar archive(path);
413     archive.open(QIODevice::ReadOnly);
414 
415     const KArchiveDirectory *archiveDir = archive.directory();
416     QStringList themeDirs;
417 
418     // Extract the dir names of the cursor themes in the archive, and
419     // append them to themeDirs
420     foreach (const QString &name, archiveDir->entries()) {
421         const KArchiveEntry *entry = archiveDir->entry(name);
422         if (entry->isDirectory() && entry->name().toLower() != "default") {
423             const KArchiveDirectory *dir = static_cast<const KArchiveDirectory *>(entry);
424             if (dir->entry("index.theme") && dir->entry("cursors")) {
425                 themeDirs << dir->name();
426             }
427         }
428     }
429 
430     if (themeDirs.isEmpty()) {
431         emit showErrorMessage(i18n("The file is not a valid icon theme archive."));
432         return;
433     }
434 
435     // The directory we'll install the themes to
436     QString destDir = QDir::homePath() + "/.icons/";
437     if (!QDir().mkpath(destDir)) {
438         emit showErrorMessage(i18n("Failed to create 'icons' folder."));
439         return;
440     }
441 
442     // Process each cursor theme in the archive
443     foreach (const QString &dirName, themeDirs) {
444         QDir dest(destDir + dirName);
445         if (dest.exists()) {
446             QString question = i18n(
447                 "A theme named %1 already exists in your icon "
448                 "theme folder. Do you want replace it with this one?",
449                 dirName);
450 
451             int answer = KMessageBox::warningContinueCancel(nullptr, question, i18n("Overwrite Theme?"), KStandardGuiItem::overwrite());
452 
453             if (answer != KMessageBox::Continue) {
454                 continue;
455             }
456 
457             // ### If the theme that's being replaced is the current theme, it
458             //     will cause cursor inconsistencies in newly started apps.
459         }
460 
461         // ### Should we check if a theme with the same name exists in a global theme dir?
462         //     If that's the case it will effectively replace it, even though the global theme
463         //     won't be deleted. Checking for this situation is easy, since the global theme
464         //     will be in the listview. Maybe this should never be allowed since it might
465         //     result in strange side effects (from the average users point of view). OTOH
466         //     a user might want to do this 'upgrade' a global theme.
467 
468         const KArchiveDirectory *dir = static_cast<const KArchiveDirectory *>(archiveDir->entry(dirName));
469         dir->copyTo(dest.path());
470         m_themeModel->addTheme(dest);
471     }
472 
473     archive.close();
474 
475     emit showSuccessMessage(i18n("Theme installed successfully."));
476 
477     m_themeModel->refreshList();
478 }
479 
removeThemes()480 void CursorThemeConfig::removeThemes()
481 {
482     const QModelIndexList indices = m_themeModel->match(m_themeModel->index(0, 0), CursorTheme::PendingDeletionRole, true, -1);
483     QList<QPersistentModelIndex> persistentIndices;
484     persistentIndices.reserve(indices.count());
485     std::transform(indices.constBegin(), indices.constEnd(), std::back_inserter(persistentIndices), [](const QModelIndex index) {
486         return QPersistentModelIndex(index);
487     });
488     for (const auto &idx : qAsConst(persistentIndices)) {
489         const CursorTheme *theme = m_themeModel->theme(idx);
490 
491         // Delete the theme from the harddrive
492         KIO::del(QUrl::fromLocalFile(theme->path())); // async
493 
494         // Remove the theme from the model
495         m_themeModel->removeTheme(idx);
496     }
497 
498     // TODO:
499     //  Since it's possible to substitute cursors in a system theme by adding a local
500     //  theme with the same name, we shouldn't remove the theme from the list if it's
501     //  still available elsewhere. We could add a
502     //  bool CursorThemeModel::tryAddTheme(const QString &name), and call that, but
503     //  since KIO::del() is an asynchronos operation, the theme we're deleting will be
504     //  readded to the list again before KIO has removed it.
505 }
506 
507 #include "kcmcursortheme.moc"
508