1 | /* |
2 | This file is part of the KDE libraries |
3 | SPDX-FileCopyrightText: 2020 David Faure <faure@kde.org> |
4 | |
5 | SPDX-License-Identifier: LGPL-2.0-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL |
6 | */ |
7 | |
8 | #include "kprocessrunner_p.h" |
9 | |
10 | #if defined(Q_OS_LINUX) && !defined(Q_OS_ANDROID) |
11 | #include "systemd/scopedprocessrunner_p.h" |
12 | #include "systemd/systemdprocessrunner_p.h" |
13 | #endif |
14 | |
15 | #include "config-kiogui.h" |
16 | #include "kiogui_debug.h" |
17 | |
18 | #include "desktopexecparser.h" |
19 | #include "gpudetection_p.h" |
20 | #include "krecentdocument.h" |
21 | #include <KDesktopFile> |
22 | #include <KLocalizedString> |
23 | #include <KWindowSystem> |
24 | |
25 | #if HAVE_WAYLAND |
26 | #include <KWaylandExtras> |
27 | #endif |
28 | |
29 | #ifdef WITH_QTDBUS |
30 | #include <QDBusConnection> |
31 | #include <QDBusInterface> |
32 | #include <QDBusReply> |
33 | |
34 | #include "dbusactivationrunner_p.h" |
35 | #endif |
36 | |
37 | #include <QDir> |
38 | #include <QFileInfo> |
39 | #include <QGuiApplication> |
40 | #include <QProcess> |
41 | #include <QStandardPaths> |
42 | #include <QString> |
43 | #include <QTimer> |
44 | #include <QUuid> |
45 | |
46 | #ifdef Q_OS_WIN |
47 | #include "windows.h" |
48 | |
49 | #include "shellapi.h" // Must be included after "windows.h" |
50 | #endif |
51 | |
52 | static int s_instanceCount = 0; // for the unittest |
53 | |
54 | KProcessRunner::KProcessRunner() |
55 | : m_process{new KProcess} |
56 | { |
57 | ++s_instanceCount; |
58 | } |
59 | |
60 | static KProcessRunner *makeInstance() |
61 | { |
62 | #if defined(Q_OS_LINUX) && defined(WITH_QTDBUS) |
63 | switch (SystemdProcessRunner::modeAvailable()) { |
64 | case KProcessRunner::SystemdAsService: |
65 | return new SystemdProcessRunner(); |
66 | case KProcessRunner::SystemdAsScope: |
67 | return new ScopedProcessRunner(); |
68 | default: |
69 | #else |
70 | { |
71 | #endif |
72 | return new ForkingProcessRunner(); |
73 | } |
74 | } |
75 | |
76 | #ifndef Q_OS_ANDROID |
77 | static void modifyEnv(KProcess &process, QProcessEnvironment mod) |
78 | { |
79 | QProcessEnvironment env = process.processEnvironment(); |
80 | if (env.isEmpty()) { |
81 | env = QProcessEnvironment::systemEnvironment(); |
82 | } |
83 | env.insert(e: mod); |
84 | process.setProcessEnvironment(env); |
85 | } |
86 | #endif |
87 | |
88 | KProcessRunner *KProcessRunner::fromApplication(const KService::Ptr &service, |
89 | const QString &serviceEntryPath, |
90 | const QList<QUrl> &urls, |
91 | const QString &actionName, |
92 | KIO::ApplicationLauncherJob::RunFlags flags, |
93 | const QString &suggestedFileName, |
94 | const QByteArray &asn) |
95 | { |
96 | #ifdef WITH_QTDBUS |
97 | // special case for applicationlauncherjob |
98 | // FIXME: KProcessRunner is currently broken and fails to prepare the m_urls member |
99 | // DBusActivationRunner uses, which then only calls "Activate", not "Open". |
100 | // Possibly will need some special mode of DesktopExecParser |
101 | // for the D-Bus activation call scenario to handle URLs with protocols |
102 | // the invoked service/executable might not support. |
103 | KProcessRunner *instance; |
104 | const bool notYetSupportedOpenActivationNeeded = !urls.isEmpty(); |
105 | if (!notYetSupportedOpenActivationNeeded && DBusActivationRunner::activationPossible(service, flags, suggestedFileName)) { |
106 | const auto actions = service->actions(); |
107 | auto action = std::find_if(first: actions.cbegin(), last: actions.cend(), pred: [actionName](const KServiceAction &action) { |
108 | return action.name() == actionName; |
109 | }); |
110 | if (!actionName.isEmpty() && action == actions.cend()) { |
111 | qCWarning(KIO_GUI) << "Requested action" << actionName << "cannot be found for" << service->name(); |
112 | } |
113 | instance = new DBusActivationRunner(action != actions.cend() ? action->name() : QString()); |
114 | } else { |
115 | instance = makeInstance(); |
116 | } |
117 | #else |
118 | KProcessRunner *instance = makeInstance(); |
119 | #endif |
120 | |
121 | if (!service->isValid()) { |
122 | instance->emitDelayedError(i18n("The desktop entry file\n%1\nis not valid." , serviceEntryPath)); |
123 | return instance; |
124 | } |
125 | instance->m_executable = KIO::DesktopExecParser::executablePath(execLine: service->exec()); |
126 | |
127 | KIO::DesktopExecParser execParser(*service, urls); |
128 | execParser.setUrlsAreTempFiles(flags & KIO::ApplicationLauncherJob::DeleteTemporaryFiles); |
129 | execParser.setSuggestedFileName(suggestedFileName); |
130 | const QStringList args = execParser.resultingArguments(); |
131 | if (args.isEmpty()) { |
132 | instance->emitDelayedError(errorMsg: execParser.errorMessage()); |
133 | return instance; |
134 | } |
135 | |
136 | qCDebug(KIO_GUI) << "Starting process:" << args; |
137 | *instance->m_process << args; |
138 | |
139 | #ifndef Q_OS_ANDROID |
140 | if (service->runOnDiscreteGpu()) { |
141 | modifyEnv(process&: *instance->m_process, mod: KIO::discreteGpuEnvironment()); |
142 | } |
143 | #endif |
144 | |
145 | QString workingDir(service->workingDirectory()); |
146 | if (workingDir.isEmpty() && !urls.isEmpty() && urls.first().isLocalFile()) { |
147 | // systemd requires working directory to be normalized, or '~' |
148 | workingDir = QFileInfo(urls.first().toLocalFile()).canonicalPath(); |
149 | } |
150 | instance->m_process->setWorkingDirectory(workingDir); |
151 | |
152 | if ((flags & KIO::ApplicationLauncherJob::DeleteTemporaryFiles) == 0) { |
153 | // Remember we opened those urls, for the "recent documents" menu in kicker |
154 | for (const QUrl &url : urls) { |
155 | KRecentDocument::add(url, desktopEntryName: service->desktopEntryName()); |
156 | } |
157 | } |
158 | |
159 | instance->init(service, serviceEntryPath, userVisibleName: service->name(), asn); |
160 | return instance; |
161 | } |
162 | |
163 | KProcessRunner *KProcessRunner::fromCommand(const QString &cmd, |
164 | const QString &desktopName, |
165 | const QString &execName, |
166 | const QByteArray &asn, |
167 | const QString &workingDirectory, |
168 | const QProcessEnvironment &environment) |
169 | { |
170 | auto instance = makeInstance(); |
171 | |
172 | instance->m_executable = KIO::DesktopExecParser::executablePath(execLine: execName); |
173 | instance->m_cmd = cmd; |
174 | #ifdef Q_OS_WIN |
175 | if (cmd.startsWith(QLatin1String("wt.exe" )) || cmd.startsWith(QLatin1String("pwsh.exe" )) || cmd.startsWith(QLatin1String("powershell.exe" ))) { |
176 | instance->m_process->setCreateProcessArgumentsModifier([](QProcess::CreateProcessArguments *args) { |
177 | args->flags |= CREATE_NEW_CONSOLE; |
178 | args->startupInfo->dwFlags &= ~STARTF_USESTDHANDLES; |
179 | }); |
180 | const int firstSpace = cmd.indexOf(QLatin1Char(' ')); |
181 | instance->m_process->setProgram(cmd.left(firstSpace)); |
182 | instance->m_process->setNativeArguments(cmd.mid(firstSpace + 1)); |
183 | } else |
184 | #endif |
185 | instance->m_process->setShellCommand(cmd); |
186 | |
187 | instance->initFromDesktopName(desktopName, execName, asn, workingDirectory, environment); |
188 | return instance; |
189 | } |
190 | |
191 | KProcessRunner *KProcessRunner::fromExecutable(const QString &executable, |
192 | const QStringList &args, |
193 | const QString &desktopName, |
194 | const QByteArray &asn, |
195 | const QString &workingDirectory, |
196 | const QProcessEnvironment &environment) |
197 | { |
198 | const QString actualExec = QStandardPaths::findExecutable(executableName: executable); |
199 | if (actualExec.isEmpty()) { |
200 | qCInfo(KIO_GUI) << "Could not find an executable named:" << executable; |
201 | return {}; |
202 | } |
203 | |
204 | auto instance = makeInstance(); |
205 | |
206 | instance->m_executable = KIO::DesktopExecParser::executablePath(execLine: executable); |
207 | instance->m_process->setProgram(exe: actualExec, args); |
208 | instance->initFromDesktopName(desktopName, execName: executable, asn, workingDirectory, environment); |
209 | return instance; |
210 | } |
211 | |
212 | void KProcessRunner::initFromDesktopName(const QString &desktopName, |
213 | const QString &execName, |
214 | const QByteArray &asn, |
215 | const QString &workingDirectory, |
216 | const QProcessEnvironment &environment) |
217 | { |
218 | if (!workingDirectory.isEmpty()) { |
219 | m_process->setWorkingDirectory(workingDirectory); |
220 | } |
221 | m_process->setProcessEnvironment(environment); |
222 | if (!desktopName.isEmpty()) { |
223 | KService::Ptr service = KService::serviceByDesktopName(name: desktopName); |
224 | if (service) { |
225 | if (m_executable.isEmpty()) { |
226 | m_executable = KIO::DesktopExecParser::executablePath(execLine: service->exec()); |
227 | } |
228 | init(service, serviceEntryPath: service->entryPath(), userVisibleName: service->name(), asn); |
229 | return; |
230 | } |
231 | } |
232 | init(service: KService::Ptr(), serviceEntryPath: QString{}, userVisibleName: execName /*user-visible name*/, asn); |
233 | } |
234 | |
235 | void KProcessRunner::init(const KService::Ptr &service, const QString &serviceEntryPath, const QString &userVisibleName, const QByteArray &asn) |
236 | { |
237 | m_serviceEntryPath = serviceEntryPath; |
238 | if (service && !serviceEntryPath.isEmpty() && !KDesktopFile::isAuthorizedDesktopFile(path: serviceEntryPath)) { |
239 | qCWarning(KIO_GUI) << "No authorization to execute" << serviceEntryPath; |
240 | emitDelayedError(i18n("You are not authorized to execute this file." )); |
241 | return; |
242 | } |
243 | |
244 | if (service) { |
245 | m_service = service; |
246 | // Store the desktop name, used by debug output and for the systemd unit name |
247 | m_desktopName = service->menuId(); |
248 | if (m_desktopName.isEmpty() && m_executable == QLatin1String("systemsettings" )) { |
249 | m_desktopName = QStringLiteral("systemsettings.desktop" ); |
250 | } |
251 | if (m_desktopName.endsWith(s: QLatin1String(".desktop" ))) { // always true, in theory |
252 | m_desktopName.chop(n: strlen(s: ".desktop" )); |
253 | } |
254 | if (m_desktopName.isEmpty()) { // desktop files not in the menu |
255 | // desktopEntryName is lowercase so this is only a fallback |
256 | m_desktopName = service->desktopEntryName(); |
257 | } |
258 | m_desktopFilePath = QFileInfo(serviceEntryPath).absoluteFilePath(); |
259 | m_description = service->name(); |
260 | if (!service->genericName().isEmpty()) { |
261 | m_description.append(QStringLiteral(" - %1" ).arg(a: service->genericName())); |
262 | } |
263 | } else { |
264 | m_description = userVisibleName; |
265 | } |
266 | |
267 | #if HAVE_X11 |
268 | static bool isX11 = QGuiApplication::platformName() == QLatin1String("xcb" ); |
269 | if (isX11) { |
270 | bool silent; |
271 | QByteArray wmclass; |
272 | const bool startup_notify = (asn != "0" && KIOGuiPrivate::checkStartupNotify(service: service.data(), silent_arg: &silent, wmclass_arg: &wmclass)); |
273 | if (startup_notify) { |
274 | m_startupId.initId(id: asn); |
275 | m_startupId.setupStartupEnv(); |
276 | KStartupInfoData data; |
277 | data.setHostname(); |
278 | // When it comes from a desktop file, m_executable can be a full shell command, so <bin> here is not 100% reliable. |
279 | // E.g. it could be "cd", which isn't an existing binary. It's just a heuristic anyway. |
280 | const QString bin = KIO::DesktopExecParser::executableName(execLine: m_executable); |
281 | data.setBin(bin); |
282 | if (!userVisibleName.isEmpty()) { |
283 | data.setName(userVisibleName); |
284 | } else if (service && !service->name().isEmpty()) { |
285 | data.setName(service->name()); |
286 | } |
287 | data.setDescription(i18n("Launching %1" , data.name())); |
288 | if (service && !service->icon().isEmpty()) { |
289 | data.setIcon(service->icon()); |
290 | } |
291 | if (!wmclass.isEmpty()) { |
292 | data.setWMClass(wmclass); |
293 | } |
294 | if (silent) { |
295 | data.setSilent(KStartupInfoData::Yes); |
296 | } |
297 | if (service && !serviceEntryPath.isEmpty()) { |
298 | data.setApplicationId(serviceEntryPath); |
299 | } |
300 | KStartupInfo::sendStartup(id: m_startupId, data); |
301 | } |
302 | } |
303 | #else |
304 | Q_UNUSED(userVisibleName); |
305 | #endif |
306 | |
307 | #if HAVE_WAYLAND |
308 | if (KWindowSystem::isPlatformWayland()) { |
309 | if (!asn.isEmpty()) { |
310 | m_process->setEnv(QStringLiteral("XDG_ACTIVATION_TOKEN" ), value: QString::fromUtf8(ba: asn)); |
311 | } else { |
312 | auto window = qGuiApp->focusWindow(); |
313 | if (!window && !qGuiApp->allWindows().isEmpty()) { |
314 | window = qGuiApp->allWindows().constFirst(); |
315 | } |
316 | if (window) { |
317 | const int launchedSerial = KWaylandExtras::lastInputSerial(window); |
318 | m_waitingForXdgToken = true; |
319 | connect( |
320 | sender: KWaylandExtras::self(), |
321 | signal: &KWaylandExtras::xdgActivationTokenArrived, |
322 | context: m_process.get(), |
323 | slot: [this, launchedSerial](int tokenSerial, const QString &token) { |
324 | if (tokenSerial == launchedSerial) { |
325 | m_process->setEnv(QStringLiteral("XDG_ACTIVATION_TOKEN" ), value: token); |
326 | m_waitingForXdgToken = false; |
327 | startProcess(); |
328 | } |
329 | }, |
330 | type: Qt::SingleShotConnection); |
331 | KWaylandExtras::requestXdgActivationToken(win: window, serial: launchedSerial, app_id: resolveServiceAlias()); |
332 | } |
333 | } |
334 | } |
335 | #endif |
336 | |
337 | if (!m_waitingForXdgToken) { |
338 | startProcess(); |
339 | } |
340 | } |
341 | |
342 | void ForkingProcessRunner::startProcess() |
343 | { |
344 | connect(sender: m_process.get(), signal: &QProcess::finished, context: this, slot: &ForkingProcessRunner::slotProcessExited); |
345 | connect(sender: m_process.get(), signal: &QProcess::started, context: this, slot: &ForkingProcessRunner::slotProcessStarted, type: Qt::QueuedConnection); |
346 | connect(sender: m_process.get(), signal: &QProcess::errorOccurred, context: this, slot: &ForkingProcessRunner::slotProcessError); |
347 | m_process->start(); |
348 | } |
349 | |
350 | bool ForkingProcessRunner::waitForStarted(int timeout) |
351 | { |
352 | if (m_process->state() == QProcess::NotRunning && m_waitingForXdgToken) { |
353 | QEventLoop loop; |
354 | QObject::connect(sender: m_process.get(), signal: &QProcess::stateChanged, context: &loop, slot: &QEventLoop::quit); |
355 | QTimer::singleShot(interval: timeout, receiver: &loop, slot: &QEventLoop::quit); |
356 | loop.exec(); |
357 | } |
358 | return m_process->waitForStarted(msecs: timeout); |
359 | } |
360 | |
361 | void ForkingProcessRunner::slotProcessError(QProcess::ProcessError errorCode) |
362 | { |
363 | // E.g. the process crashed. |
364 | // This is unlikely to happen while the ApplicationLauncherJob is still connected to the KProcessRunner. |
365 | // So the emit does nothing, this is really just for debugging. |
366 | qCDebug(KIO_GUI) << name() << "error=" << errorCode << m_process->errorString(); |
367 | Q_EMIT error(errorString: m_process->errorString()); |
368 | } |
369 | |
370 | void ForkingProcessRunner::slotProcessStarted() |
371 | { |
372 | setPid(m_process->processId()); |
373 | } |
374 | |
375 | void KProcessRunner::setPid(qint64 pid) |
376 | { |
377 | if (!m_pid && pid) { |
378 | qCDebug(KIO_GUI) << "Setting PID" << pid << "for:" << name(); |
379 | m_pid = pid; |
380 | #if HAVE_X11 |
381 | if (!m_startupId.isNull()) { |
382 | KStartupInfoData data; |
383 | data.addPid(pid: static_cast<int>(m_pid)); |
384 | KStartupInfo::sendChange(id: m_startupId, data); |
385 | KStartupInfo::resetStartupEnv(); |
386 | } |
387 | #endif |
388 | Q_EMIT processStarted(pid); |
389 | } |
390 | } |
391 | |
392 | KProcessRunner::~KProcessRunner() |
393 | { |
394 | // This destructor deletes m_process, since it's a unique_ptr. |
395 | --s_instanceCount; |
396 | } |
397 | |
398 | int KProcessRunner::instanceCount() |
399 | { |
400 | return s_instanceCount; |
401 | } |
402 | |
403 | void KProcessRunner::terminateStartupNotification() |
404 | { |
405 | #if HAVE_X11 |
406 | if (!m_startupId.isNull()) { |
407 | KStartupInfoData data; |
408 | data.addPid(pid: static_cast<int>(m_pid)); // announce this pid for the startup notification has finished |
409 | data.setHostname(); |
410 | KStartupInfo::sendFinish(id: m_startupId, data); |
411 | } |
412 | #endif |
413 | } |
414 | |
415 | QString KProcessRunner::name() const |
416 | { |
417 | return !m_desktopName.isEmpty() ? m_desktopName : m_executable; |
418 | } |
419 | |
420 | // Only alphanum, ':' and '_' allowed in systemd unit names |
421 | QString KProcessRunner::escapeUnitName(const QString &input) |
422 | { |
423 | QString res; |
424 | const QByteArray bytes = input.toUtf8(); |
425 | for (const unsigned char c : bytes) { |
426 | if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == ':' || c == '_' || c == '.') { |
427 | res += QLatin1Char(c); |
428 | } else { |
429 | res += QStringLiteral("\\x%1" ).arg(a: c, fieldWidth: 2, base: 16, fillChar: QLatin1Char('0')); |
430 | } |
431 | } |
432 | return res; |
433 | } |
434 | |
435 | QString KProcessRunner::resolveServiceAlias() const |
436 | { |
437 | // Don't actually load aliased desktop file to avoid having to deal with recursion |
438 | QString servName = m_service ? m_service->aliasFor() : QString{}; |
439 | if (servName.isEmpty()) { |
440 | servName = name(); |
441 | } |
442 | |
443 | return servName; |
444 | } |
445 | |
446 | void KProcessRunner::emitDelayedError(const QString &errorMsg) |
447 | { |
448 | qCWarning(KIO_GUI) << name() << errorMsg; |
449 | |
450 | terminateStartupNotification(); |
451 | // Use delayed invocation so the caller has time to connect to the signal |
452 | auto func = [this, errorMsg]() { |
453 | Q_EMIT error(errorString: errorMsg); |
454 | deleteLater(); |
455 | }; |
456 | QMetaObject::invokeMethod(object: this, function&: func, type: Qt::QueuedConnection); |
457 | } |
458 | |
459 | void ForkingProcessRunner::slotProcessExited(int exitCode, QProcess::ExitStatus exitStatus) |
460 | { |
461 | qCDebug(KIO_GUI) << name() << "exitCode=" << exitCode << "exitStatus=" << exitStatus; |
462 | #ifdef Q_OS_UNIX |
463 | if (exitCode == 127) { |
464 | #else |
465 | if (exitCode == 9009) { |
466 | #endif |
467 | const QStringList args = m_cmd.split(sep: QLatin1Char(' ')); |
468 | emitDelayedError(xi18nc("@info" , "The command <command>%1</command> could not be found." , args[0])); // Calls deleteLater(). |
469 | } else { |
470 | terminateStartupNotification(); |
471 | deleteLater(); |
472 | } |
473 | } |
474 | |
475 | bool KIOGuiPrivate::checkStartupNotify(const KService *service, bool *silent_arg, QByteArray *wmclass_arg) |
476 | { |
477 | bool silent = false; |
478 | QByteArray wmclass; |
479 | |
480 | if (service && service->startupNotify().has_value()) { |
481 | silent = !service->startupNotify().value(); |
482 | wmclass = service->property<QByteArray>(QStringLiteral("StartupWMClass" )); |
483 | } else { // non-compliant app |
484 | if (service) { |
485 | if (service->isApplication()) { // doesn't have .desktop entries needed, start as non-compliant |
486 | wmclass = "0" ; // krazy:exclude=doublequote_chars |
487 | } else { |
488 | return false; // no startup notification at all |
489 | } |
490 | } else { |
491 | #if 0 |
492 | // Create startup notification even for apps for which there shouldn't be any, |
493 | // just without any visual feedback. This will ensure they'll be positioned on the proper |
494 | // virtual desktop, and will get user timestamp from the ASN ID. |
495 | wmclass = '0'; |
496 | silent = true; |
497 | #else // That unfortunately doesn't work, when the launched non-compliant application |
498 | // launches another one that is compliant and there is any delay in between (bnc:#343359) |
499 | return false; |
500 | #endif |
501 | } |
502 | } |
503 | if (silent_arg) { |
504 | *silent_arg = silent; |
505 | } |
506 | if (wmclass_arg) { |
507 | *wmclass_arg = wmclass; |
508 | } |
509 | return true; |
510 | } |
511 | |
512 | ForkingProcessRunner::ForkingProcessRunner() |
513 | : KProcessRunner() |
514 | { |
515 | } |
516 | |
517 | #include "moc_kprocessrunner_p.cpp" |
518 | |