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