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