/* * SPDX-FileCopyrightText: 2022 Kai Uwe Broulik * SPDX-License-Identifier: GPL-2.0-or-later */ #include "kio_afc.h" #include "afc_debug.h" #include "afcapp.h" #include "afcdevice.h" #include "afcfile.h" #include "afcfilereader.h" #include "afcurl.h" #include "afcutils.h" #include #include #include #include #include #include #include #include #include using namespace KIO; // Pseudo plugin class to embed meta data class KIOPluginForMetaData : public QObject { Q_OBJECT Q_PLUGIN_METADATA(IID "org.kde.kio.worker.afc" FILE "afc.json") }; using namespace KIO; extern "C" { int Q_DECL_EXPORT kdemain(int argc, char **argv) { QCoreApplication app(argc, argv); app.setApplicationName(QStringLiteral("kio_afc")); qCDebug(KIO_AFC_LOG) << "*** Starting kio_afc"; if (argc != 4) { qCDebug(KIO_AFC_LOG) << "Usage: kio_afc protocol domain-socket1 domain-socket2"; exit(-1); } AfcWorker worker(argv[2], argv[3]); worker.dispatchLoop(); return 0; } } AfcWorker::AfcWorker(const QByteArray &poolSocket, const QByteArray &appSocket) : WorkerBase(QByteArrayLiteral("kio_afc"), poolSocket, appSocket) { const auto result = init(); Q_ASSERT(result.success()); } AfcWorker::~AfcWorker() { idevice_event_unsubscribe(); qDeleteAll(m_devices); m_devices.clear(); } Result AfcWorker::init() { bool ok; const int logLevel = qEnvironmentVariableIntValue("KIO_AFC_LOG_VERBOSITY", &ok); if (ok) { idevice_set_debug_level(logLevel); } idevice_event_subscribe( [](const idevice_event_t *event, void *user_data) { // NOTE this is executed in a different thread! static_cast(user_data)->onDeviceEvent(event); }, this); updateDeviceList(); return Result::pass(); } void AfcWorker::onDeviceEvent(const idevice_event_t *event) { // NOTE this is executed in a different thread! switch (event->event) { case IDEVICE_DEVICE_ADD: qCDebug(KIO_AFC_LOG) << "idevice event ADD for" << event->udid; addDevice(QString::fromLatin1(event->udid)); return; case IDEVICE_DEVICE_REMOVE: qCDebug(KIO_AFC_LOG) << "idevice event REMOVE for" << event->udid; removeDevice(QString::fromLatin1(event->udid)); return; #if IMOBILEDEVICE_API >= QT_VERSION_CHECK(1, 3, 0) case IDEVICE_DEVICE_PAIRED: qCDebug(KIO_AFC_LOG) << "idevice event PAIRED for" << event->udid; return; #endif } qCWarning(KIO_AFC_LOG) << "Unhandled idevice event" << event->event << "for" << event->udid; } AfcDevice *AfcWorker::deviceForUrl(const AfcUrl &afcUrl) const { return m_devices.value(afcUrl.device()); } Result AfcWorker::clientForUrl(const AfcUrl &afcUrl, AfcClient::Ptr &client) const { AfcDevice *device = deviceForUrl(afcUrl); if (!device) { return Result::fail(ERR_DOES_NOT_EXIST, afcUrl.url().toDisplayString()); } return device->client(afcUrl.appId(), client); } UDSEntry AfcWorker::overviewEntry(const QString &fileName) const { UDSEntry entry; entry.fastInsert(UDSEntry::UDS_NAME, !fileName.isEmpty() ? fileName : i18n("Apple Devices")); entry.fastInsert(UDSEntry::UDS_ICON_NAME, QStringLiteral("phone-apple-iphone")); entry.fastInsert(UDSEntry::UDS_FILE_TYPE, S_IFDIR); entry.fastInsert(UDSEntry::UDS_MIME_TYPE, QStringLiteral("inode/directory")); return entry; } UDSEntry AfcWorker::deviceEntry(const AfcDevice *device, const QString &fileName, bool asLink) const { const QString deviceId = device->id(); const QString deviceClass = device->deviceClass(); UDSEntry entry; entry.fastInsert(UDSEntry::UDS_NAME, !fileName.isEmpty() ? fileName : deviceId); if (!device->name().isEmpty()) { entry.fastInsert(UDSEntry::UDS_DISPLAY_NAME, device->name()); } // TODO prettier entry.fastInsert(UDSEntry::UDS_DISPLAY_TYPE, deviceClass); entry.fastInsert(UDSEntry::UDS_FILE_TYPE, S_IFDIR); entry.fastInsert(UDSEntry::UDS_MIME_TYPE, QStringLiteral("inode/directory")); QString iconName; // We can assume iPod running iOS/supporting imobiledevice is an iPod touch? if (deviceClass.contains(QLatin1String("iPad"))) { iconName = QStringLiteral("computer-apple-ipad"); } else if (deviceClass.contains(QLatin1String("iPod"))) { iconName = QStringLiteral("multimedia-player-apple-ipod-touch"); } else { iconName = QStringLiteral("phone-apple-iphone"); } entry.fastInsert(UDSEntry::UDS_ICON_NAME, iconName); if (asLink) { const QString contentsUrl = QStringLiteral("afc://%1/").arg(deviceId); entry.fastInsert(UDSEntry::UDS_LINK_DEST, contentsUrl); entry.fastInsert(UDSEntry::UDS_TARGET_URL, contentsUrl); } return entry; } UDSEntry AfcWorker::appsOverviewEntry(const AfcDevice *device, const QString &fileName) const { UDSEntry entry; entry.fastInsert(UDSEntry::UDS_NAME, !fileName.isEmpty() ? fileName : AfcUrl::appsTag()); entry.fastInsert(UDSEntry::UDS_DISPLAY_NAME, i18nc("Link to folder with files stored inside apps", "Apps")); entry.fastInsert(UDSEntry::UDS_ICON_NAME, QStringLiteral("folder-documents")); entry.fastInsert(UDSEntry::UDS_FILE_TYPE, S_IFDIR); const QString appsUrl = QStringLiteral("afc://%1/%2/").arg(device->id(), AfcUrl::appsTag()); entry.fastInsert(UDSEntry::UDS_LINK_DEST, appsUrl); entry.fastInsert(UDSEntry::UDS_TARGET_URL, appsUrl); return entry; } void AfcWorker::updateDeviceList() { char **devices = nullptr; int count = 0; idevice_get_device_list(&devices, &count); for (int i = 0; i < count; ++i) { const QString id = QString::fromLatin1(devices[i]); addDevice(id); } if (devices) { idevice_device_list_free(devices); } } bool AfcWorker::addDevice(const QString &id) { // NOTE this may be executed in a different thread when called from device idevice_event callback QMutexLocker locker(&m_mutex); if (m_devices.contains(id)) { return false; } auto *device = new AfcDevice(id); if (!device->isValid()) { delete device; return false; } m_devices.insert(id, device); Q_ASSERT(!device->name().isEmpty()); return true; } void AfcWorker::removeDevice(const QString &id) { // NOTE this may be executed in a different thread when called from device idevice_event callback QMutexLocker locker(&m_mutex); auto *device = m_devices.take(id); if (device) { if (m_openFile && m_openFile->client()->device() == device) { m_openFile.reset(); } delete device; } } Result AfcWorker::listDir(const QUrl &url) { qCDebug(KIO_AFC_LOG) << "list directory:" << url; const AfcUrl afcUrl(url); if (!afcUrl.isValid()) { return Result::fail(ERR_MALFORMED_URL, url.toDisplayString()); } // Don't have an empty path. // Otherwise we get "invalid URL" errors when trying to enter a subfolder. if (!url.host().isEmpty() && url.path().isEmpty()) { QUrl newUrl = url; newUrl.setPath(QStringLiteral("/")); redirection(newUrl); return Result::pass(); } if (afcUrl.device().isEmpty()) { updateDeviceList(); for (AfcDevice *device : m_devices) { UDSEntry entry = deviceEntry(device, QString(), true /*asLink*/); // When there is only one device, redirect to it right away if (m_devices.count() == 1) { redirection(QUrl(entry.stringValue(UDSEntry::UDS_TARGET_URL))); return Result::pass(); } listEntry(entry); } // We cannot just list that at the beginning because we might do a redirect listEntry(overviewEntry(QStringLiteral("."))); return Result::pass(); } AfcDevice *device = deviceForUrl(afcUrl); if (!device) { return Result::fail(ERR_DOES_NOT_EXIST, afcUrl.device()); } const QString appId = afcUrl.appId(); if (afcUrl.browseMode() == AfcUrl::BrowseMode::Apps && appId.isEmpty()) { QList apps; const auto result = device->apps(apps); if (!result.success()) { return result; } // Cannot browse apps without sharing enabled, don't list them. apps.erase(std::remove_if(apps.begin(), apps.end(), [](const AfcApp &app) { return !app.sharingEnabled(); }), apps.end()); device->fetchAppIcons(apps); for (const auto &app : apps) { listEntry(app.entry()); } listEntry(appsOverviewEntry(device, QStringLiteral("."))); return Result::pass(); } AfcClient::Ptr client; Result result = device->client(appId, client); if (!result.success()) { return result; } // Ourself must be "." UDSEntry rootEntry; result = client->entry(afcUrl.path(), rootEntry); if (!result.success()) { return result; } // NOTE this must not be "fastInsert" as AfcDevice::entry already sets a name rootEntry.replace(UDSEntry::UDS_NAME, QStringLiteral(".")); listEntry(rootEntry); QStringList files; result = client->entryList(afcUrl.path(), files); if (!result.success()) { // One can only access the "Documents" folder within an app, redirect to it if applicable if (result.error() == KIO::ERR_ACCESS_DENIED && !afcUrl.appId().isEmpty() && afcUrl.path().isEmpty()) { QUrl newUrl = url; newUrl.setPath(newUrl.path() + QLatin1String("/Documents")); qCDebug(KIO_AFC_LOG) << "Got access denied on app root folder, redirecting to Documents folder"; redirection(newUrl); return Result::pass(); } return result; } for (const QString &file : files) { QString absolutePath = afcUrl.path(); if (!absolutePath.endsWith(QLatin1Char('/')) && !file.startsWith(QLatin1Char('/'))) { absolutePath.append(QLatin1Char('/')); } absolutePath.append(file); UDSEntry entry; result = client->entry(absolutePath, entry); if (!result.success()) { qCWarning(KIO_AFC_LOG) << "Failed to list" << absolutePath << result.error() << result.errorString(); continue; } listEntry(entry); } // Add link to "Apps documents" to device root folder if (afcUrl.path().isEmpty()) { listEntry(appsOverviewEntry(device)); } return Result::pass(); } Result AfcWorker::stat(const QUrl &url) { const AfcUrl afcUrl(url); if (!afcUrl.isValid()) { return Result::fail(ERR_MALFORMED_URL, url.toDisplayString()); } // Device overview page afc:/ if (afcUrl.device().isEmpty()) { statEntry(overviewEntry()); return Result::pass(); } AfcDevice *device = deviceForUrl(afcUrl); if (!device) { return Result::fail(ERR_DOES_NOT_EXIST, url.toDisplayString()); } if (afcUrl.path().isEmpty()) { // Device file system or device app overview if (afcUrl.appId().isEmpty()) { UDSEntry rootEntry = deviceEntry(device); if (afcUrl.browseMode() == AfcUrl::BrowseMode::Apps) { rootEntry.replace(UDSEntry::UDS_DISPLAY_NAME, i18nc("Link to folder with files stored inside apps", "Apps")); } statEntry(rootEntry); return Result::pass(); } // App folder AfcApp app = device->app(afcUrl.appId()); if (!app.isValid()) { return Result::fail(KIO::ERR_DOES_NOT_EXIST, afcUrl.appId()); } device->fetchAppIcon(app); UDSEntry appEntry = app.entry(); statEntry(appEntry); return Result::pass(); } AfcClient::Ptr client; auto result = device->client(afcUrl.appId(), client); if (!result.success()) { return result; } UDSEntry entry; result = client->entry(afcUrl.path(), entry); if (!result.success()) { return result; } statEntry(entry); return Result::pass(); } void AfcWorker::guessMimeType(AfcFile &file, const QString &path) { // Determine the mimetype of the file to be retrieved, and emit it. // This is mandatory in all workers... AfcFileReader reader = file.reader(); reader.setSize(1024); const Result result = reader.read(); if (result.success()) { QMimeDatabase db; const QString fileName = path.section(QLatin1Char('/'), -1, -1); QMimeType mime = db.mimeTypeForFileNameAndData(fileName, reader.data()); mimeType(mime.name()); } file.seek(0); } Result AfcWorker::get(const QUrl &url) { const AfcUrl afcUrl(url); AfcClient::Ptr client; auto result = clientForUrl(afcUrl, client); if (!result.success()) { return result; } UDSEntry entry; result = client->entry(afcUrl.path(), entry); if (!result.success()) { return result; } AfcFile file(client, afcUrl.path()); result = file.open(QIODevice::ReadOnly); if (!result.success()) { return result; } const auto size = entry.numberValue(UDSEntry::UDS_SIZE, 0); totalSize(size); guessMimeType(file, afcUrl.path()); position(0); AfcFileReader reader = file.reader(); reader.setSize(size); while (reader.hasMore()) { const auto result = reader.read(); if (!result.success()) { return result; } data(reader.data()); } return Result::pass(); } Result AfcWorker::put(const QUrl &url, int permissions, JobFlags flags) { Q_UNUSED(permissions); const AfcUrl afcUrl(url); AfcClient::Ptr client; Result result = clientForUrl(afcUrl, client); if (!result.success()) { return result; } UDSEntry entry; result = client->entry(afcUrl.path(), entry); const bool exists = result.error() != ERR_DOES_NOT_EXIST; if (exists && !flags.testFlag(Overwrite) && !flags.testFlag(Resume)) { if (S_ISDIR(entry.numberValue(UDSEntry::UDS_FILE_TYPE))) { return Result::fail(ERR_DIR_ALREADY_EXIST, afcUrl.path()); } return Result::fail(ERR_FILE_ALREADY_EXIST, afcUrl.path()); } AfcFile file(client, afcUrl.path()); if (flags.testFlag(Resume)) { result = file.open(QIODevice::Append); } else { result = file.open(QIODevice::WriteOnly); } if (!result.success()) { return result; } int readDataResult = 0; do { QByteArray buffer; dataReq(); readDataResult = readData(buffer); if (readDataResult < 0) { return Result::fail(ERR_CANNOT_READ, QStringLiteral("readData result was %1").arg(readDataResult)); } uint32_t bytesWritten = 0; const auto result = file.write(buffer, bytesWritten); if (!result.success()) { return result; } } while (readDataResult > 0); const QString modifiedMeta = metaData(QStringLiteral("modified")); if (!modifiedMeta.isEmpty()) { const QDateTime mtime = QDateTime::fromString(modifiedMeta, Qt::ISODate); if (mtime.isValid() && !client->setModificationTime(afcUrl.path(), mtime).success()) { qCWarning(KIO_AFC_LOG) << "Failed to set mtime for" << afcUrl.path() << "in put"; } } return Result::pass(); } Result AfcWorker::open(const QUrl &url, QIODevice::OpenMode mode) { // TODO fail if already open? const AfcUrl afcUrl(url); AfcClient::Ptr client; Result result = clientForUrl(afcUrl, client); if (!result.success()) { return result; } UDSEntry entry; result = client->entry(afcUrl.path(), entry); if (!result.success()) { return result; } auto file = std::make_unique(client, afcUrl.path()); result = file->open(mode); if (!result.success()) { return result; } if (mode.testFlag(QIODevice::ReadOnly) && !mode.testFlag(QIODevice::Append)) { guessMimeType(*file, afcUrl.path()); } m_openFile = std::move(file); totalSize(entry.numberValue(UDSEntry::UDS_SIZE, 0)); position(0); return Result::pass(); } Result AfcWorker::read(filesize_t bytesRequested) { if (!m_openFile) { return Result::fail(ERR_CANNOT_READ, i18n("Cannot read without opening file first")); } AfcFileReader reader = m_openFile->reader(); reader.setSize(bytesRequested); while (reader.hasMore()) { const Result result = reader.read(); if (!result.success()) { return result; } data(reader.data()); } return Result::pass(); } Result AfcWorker::seek(filesize_t offset) { if (!m_openFile) { return Result::fail(ERR_CANNOT_SEEK, i18n("Cannot seek without opening file first")); } const Result result = m_openFile->seek(offset); if (result.success()) { position(offset); } return result; } Result AfcWorker::truncate(filesize_t length) { if (!m_openFile) { return Result::fail(ERR_CANNOT_TRUNCATE, QStringLiteral("Cannot truncate without opening file first")); } Result result = m_openFile->truncate(length); if (result.success()) { truncated(length); } return result; } Result AfcWorker::write(const QByteArray &data) { if (!m_openFile) { return Result::fail(ERR_CANNOT_WRITE, i18n("Cannot write without opening file first")); } uint32_t bytesWritten = 0; const Result result = m_openFile->write(data, bytesWritten); if (result.success()) { written(bytesWritten); } return result; } Result AfcWorker::close() { if (!m_openFile) { return Result::fail(ERR_INTERNAL, QStringLiteral("Cannot close what is not open")); } const Result result = m_openFile->close(); if (result.success()) { m_openFile.reset(); } return result; } Result AfcWorker::copy(const QUrl &src, const QUrl &dest, int permissions, JobFlags flags) { Q_UNUSED(permissions); const AfcUrl srcAfcUrl(src); const AfcUrl destAfcUrl(dest); if (srcAfcUrl.device() != destAfcUrl.device()) { // Let KIO handle copying onto, off the, and between devices return Result::fail(ERR_UNSUPPORTED_ACTION); } AfcClient::Ptr client; Result result = clientForUrl(srcAfcUrl, client); if (!result.success()) { return result; } UDSEntry srcEntry; result = client->entry(srcAfcUrl.path(), srcEntry); if (!result.success()) { return result; } UDSEntry destEntry; result = client->entry(destAfcUrl.path(), destEntry); const bool exists = result.error() != ERR_DOES_NOT_EXIST; if (exists && !flags.testFlag(Overwrite)) { if (S_ISDIR(destEntry.numberValue(UDSEntry::UDS_FILE_TYPE))) { return Result::fail(ERR_DIR_ALREADY_EXIST, destAfcUrl.path()); } return Result::fail(ERR_FILE_ALREADY_EXIST, destAfcUrl.path()); } AfcFile srcFile(client, srcAfcUrl.path()); result = srcFile.open(QIODevice::ReadOnly); if (!result.success()) { return result; } AfcFile destFile(client, destAfcUrl.path()); if (flags.testFlag(Resume)) { result = destFile.open(QIODevice::Append); } else { result = destFile.open(QIODevice::WriteOnly); } if (!result.success()) { return result; } auto cleanup = qScopeGuard([&client, &destAfcUrl] { qCInfo(KIO_AFC_LOG) << "Cleaning up leftover file" << destAfcUrl.path(); // NOTE cannot emit failure here because emitResult // will already have been called before auto result = client->del(destAfcUrl.path()); if (!result.success()) { qCWarning(KIO_AFC_LOG) << "Failed to clean up" << result.error() << result.errorString(); } }); const auto size = srcEntry.numberValue(UDSEntry::UDS_SIZE, 0); totalSize(size); AfcFileReader reader = srcFile.reader(); reader.setSize(size); KIO::filesize_t copied = 0; while (!wasKilled() && reader.hasMore()) { auto result = reader.read(); if (!result.success()) { return result; } const QByteArray chunk = reader.data(); uint32_t bytesWritten = 0; result = destFile.write(chunk, bytesWritten); if (!result.success()) { return result; } // TODO check if bytesWritten matches reader.data().size()? copied += chunk.size(); processedSize(copied); } cleanup.dismiss(); destFile.close(); // TODO check if conversion back and forth QDateTime is too expensive when copying many files const QDateTime mtime = QDateTime::fromSecsSinceEpoch(srcEntry.numberValue(KIO::UDSEntry::UDS_MODIFICATION_TIME, 0)); if (mtime.isValid()) { client->setModificationTime(destAfcUrl.path(), mtime); } return Result::pass(); } Result AfcWorker::del(const QUrl &url, bool isFile) { const AfcUrl afcUrl(url); AfcClient::Ptr client; Result result = clientForUrl(afcUrl, client); if (result.success()) { if (isFile) { result = client->del(afcUrl.path()); } else { result = client->delRecursively(afcUrl.path()); } } return result; } Result AfcWorker::rename(const QUrl &url, const QUrl &dest, JobFlags flags) { const AfcUrl srcAfcUrl(url); const AfcUrl destAfcUrl(dest); if (srcAfcUrl.device() != destAfcUrl.device()) { return Result::fail(ERR_CANNOT_RENAME, i18n("Cannot rename between devices.")); } AfcClient::Ptr client; Result result = clientForUrl(srcAfcUrl, client); if (result.success()) { result = client->rename(srcAfcUrl.path(), destAfcUrl.path(), flags); } return result; } Result AfcWorker::symlink(const QString &target, const QUrl &dest, JobFlags flags) { const AfcUrl destAfcUrl(dest); AfcClient::Ptr client; Result result = clientForUrl(destAfcUrl, client); if (result.success()) { result = client->symlink(target, destAfcUrl.path(), flags); } return result; } Result AfcWorker::mkdir(const QUrl &url, int permissions) { Q_UNUSED(permissions) const AfcUrl afcUrl(url); AfcClient::Ptr client; Result result = clientForUrl(afcUrl, client); if (result.success()) { result = client->mkdir(afcUrl.path()); } return result; } Result AfcWorker::setModificationTime(const QUrl &url, const QDateTime &mtime) { const AfcUrl afcUrl(url); AfcClient::Ptr client; Result result = clientForUrl(afcUrl, client); if (result.success()) { result = client->setModificationTime(afcUrl.path(), mtime); } return result; } Result AfcWorker::fileSystemFreeSpace(const QUrl &url) { // TODO FileSystemFreeSpaceJob does not follow redirects! const AfcUrl afcUrl(url); if (afcUrl.device().isEmpty() && m_devices.count() == 1) { return fileSystemFreeSpace(QUrl(QStringLiteral("afc://%1/").arg((*m_devices.constBegin())->id()))); } AfcClient::Ptr client; const Result result = clientForUrl(afcUrl, client); if (!result.success()) { return result; } const AfcDiskUsage diskUsage(client); if (!diskUsage.isValid()) { return Result::fail(ERR_CANNOT_STAT, url.toDisplayString()); } setMetaData(QStringLiteral("total"), QString::number(diskUsage.total())); setMetaData(QStringLiteral("available"), QString::number(diskUsage.free())); return Result::pass(); } #include "kio_afc.moc"