// This file Copyright © 2009-2022 Mnemosyne LLC. // It may be used under GPLv2 (SPDX: GPL-2.0-only), GPLv3 (SPDX: GPL-3.0-only), // or any future license endorsed by Mnemosyne LLC. // License text can be found in the licenses/ folder. #include "Application.h" #include #include #include #include #include #include #include #include #include #include #include #include #ifdef QT_DBUS_LIB #include #include #include #endif #include #include #include #include #include "AddData.h" #include "Formatter.h" #include "InteropHelper.h" #include "MainWindow.h" #include "OptionsDialog.h" #include "Prefs.h" #include "Session.h" #include "TorrentModel.h" #include "WatchDir.h" namespace { std::array const Opts = { tr_option{ 'g', "config-dir", "Where to look for configuration files", "g", true, "" }, { 'm', "minimized", "Start minimized in system tray", "m", false, nullptr }, { 'p', "port", "Port to use when connecting to an existing session", "p", true, "" }, { 'r', "remote", "Connect to an existing session at the specified hostname", "r", true, "" }, { 'u', "username", "Username to use when connecting to an existing session", "u", true, "" }, { 'v', "version", "Show version number and exit", "v", false, nullptr }, { 'w', "password", "Password to use when connecting to an existing session", "w", true, "" }, { 0, nullptr, nullptr, nullptr, false, nullptr } }; char const* getUsage() { return "Usage:\n" " transmission [OPTIONS...] [torrent files]"; } auto constexpr StatsRefreshIntervalMsec = int{ 3000 }; auto constexpr SessionRefreshIntervalMsec = int{ 3000 }; auto constexpr ModelRefreshIntervalMsec = int{ 3000 }; bool loadTranslation(QTranslator& translator, QString const& name, QLocale const& locale, QStringList const& search_directories) { for (QString const& directory : search_directories) { if (translator.load(locale, name, QStringLiteral("_"), directory)) { return true; } } return false; } [[nodiscard]] auto makeWindowIcon() { // first, try to load it from the system theme if (auto icon = QIcon::fromTheme(QStringLiteral("transmission")); !icon.isNull()) { return icon; } // if that fails, use our own as the fallback return QIcon{ QStringLiteral(":/icons/transmission.svg") }; } } // namespace Application::Application(int& argc, char** argv) : QApplication(argc, argv) , config_name_{ QStringLiteral("transmission") } , display_name_{ QStringLiteral("transmission-qt") } , start_now_regex_{ QRegularExpression(QStringLiteral(R"rgx(start-now\((\d+)\))rgx")) } { setApplicationName(config_name_); loadTranslations(); #if defined(_WIN32) || defined(__APPLE__) if (QIcon::themeName().isEmpty()) { QIcon::setThemeName(QStringLiteral("Faenza")); } #endif setWindowIcon(makeWindowIcon()); #ifdef __APPLE__ setAttribute(Qt::AA_DontShowIconsInMenus); #endif // parse the command-line arguments int c = 0; bool minimized = false; char const* optarg = nullptr; QString host; QString port; QString username; QString password; QString config_dir; QStringList filenames; while ((c = tr_getopt(getUsage(), argc, const_cast(argv), Opts.data(), &optarg)) != TR_OPT_DONE) { switch (c) { case 'g': config_dir = QString::fromUtf8(optarg); break; case 'p': port = QString::fromUtf8(optarg); break; case 'r': host = QString::fromUtf8(optarg); break; case 'u': username = QString::fromUtf8(optarg); break; case 'w': password = QString::fromUtf8(optarg); break; case 'm': minimized = true; break; case 'v': qInfo() << qPrintable(display_name_) << LONG_VERSION_STRING; quitLater(); return; case TR_OPT_ERR: qWarning() << qPrintable(QObject::tr("Invalid option")); tr_getopt_usage(qPrintable(display_name_), getUsage(), Opts.data()); quitLater(); return; default: filenames.append(QString::fromUtf8(optarg)); break; } } // try to delegate the work to an existing copy of Transmission // before starting ourselves... InteropHelper const interop_client; if (interop_client.isConnected()) { bool delegated = false; for (QString const& filename : filenames) { auto const a = AddData(filename); QString metainfo; switch (a.type) { case AddData::URL: metainfo = a.url.toString(); break; case AddData::MAGNET: metainfo = a.magnet; break; case AddData::FILENAME: case AddData::METAINFO: metainfo = QString::fromUtf8(a.toBase64()); break; default: break; } if (!metainfo.isEmpty() && interop_client.addMetainfo(metainfo)) { delegated = true; } } if (delegated) { quitLater(); return; } } // set the fallback config dir if (config_dir.isNull()) { config_dir = QString::fromStdString(tr_getDefaultConfigDir("transmission")); } // ensure our config directory exists QDir const dir(config_dir); if (!dir.exists()) { dir.mkpath(config_dir); } // is this the first time we've run transmission? bool const first_time = !dir.exists(QStringLiteral("settings.json")); // initialize the prefs prefs_ = std::make_unique(config_dir); if (!host.isNull()) { prefs_->set(Prefs::SESSION_REMOTE_HOST, host); } if (!port.isNull()) { prefs_->set(Prefs::SESSION_REMOTE_PORT, port.toUInt()); } if (!username.isNull()) { prefs_->set(Prefs::SESSION_REMOTE_USERNAME, username); } if (!password.isNull()) { prefs_->set(Prefs::SESSION_REMOTE_PASSWORD, password); } if (!host.isNull() || !port.isNull() || !username.isNull() || !password.isNull()) { prefs_->set(Prefs::SESSION_IS_REMOTE, true); } if (prefs_->getBool(Prefs::START_MINIMIZED)) { minimized = true; } // start as minimized only if the system tray present if (!prefs_->getBool(Prefs::SHOW_TRAY_ICON)) { minimized = false; } session_ = std::make_unique(config_dir, *prefs_); model_ = std::make_unique(*prefs_); window_ = std::make_unique(*session_, *prefs_, *model_, minimized); watch_dir_ = std::make_unique(*model_); connect(this, &QCoreApplication::aboutToQuit, this, &Application::saveGeometry); connect(model_.get(), &TorrentModel::torrentsAdded, this, &Application::onTorrentsAdded); connect(model_.get(), &TorrentModel::torrentsCompleted, this, &Application::onTorrentsCompleted); connect(model_.get(), &TorrentModel::torrentsEdited, this, &Application::onTorrentsEdited); connect(model_.get(), &TorrentModel::torrentsNeedInfo, this, &Application::onTorrentsNeedInfo); connect(prefs_.get(), &Prefs::changed, this, &Application::refreshPref); connect(session_.get(), &Session::sourceChanged, this, &Application::onSessionSourceChanged); connect(session_.get(), &Session::torrentsRemoved, model_.get(), &TorrentModel::removeTorrents); connect(session_.get(), &Session::torrentsUpdated, model_.get(), &TorrentModel::updateTorrents); connect(watch_dir_.get(), &WatchDir::torrentFileAdded, this, qOverload(&Application::addTorrent)); // init from preferences for (auto const key : { Prefs::DIR_WATCH }) { refreshPref(key); } QTimer* timer = &model_timer_; connect(timer, &QTimer::timeout, this, &Application::refreshTorrents); timer->setSingleShot(false); timer->setInterval(ModelRefreshIntervalMsec); timer->start(); timer = &stats_timer_; connect(timer, &QTimer::timeout, session_.get(), &Session::refreshSessionStats); timer->setSingleShot(false); timer->setInterval(StatsRefreshIntervalMsec); timer->start(); timer = &session_timer_; connect(timer, &QTimer::timeout, session_.get(), &Session::refreshSessionInfo); timer->setSingleShot(false); timer->setInterval(SessionRefreshIntervalMsec); timer->start(); maybeUpdateBlocklist(); if (!first_time) { session_->restart(); } else { window_->openSession(); } if (!prefs_->getBool(Prefs::USER_HAS_GIVEN_INFORMED_CONSENT)) { auto* dialog = new QMessageBox( QMessageBox::Information, QString(), tr("Transmission is a file sharing program."), QMessageBox::Ok | QMessageBox::Cancel, window_.get()); dialog->setInformativeText( tr("When you run a torrent, its data will be made available to others by means of upload. " "Any content you share is your sole responsibility.")); dialog->button(QMessageBox::Ok)->setText(tr("I &Agree")); dialog->setDefaultButton(QMessageBox::Ok); dialog->setModal(true); connect(dialog, &QDialog::finished, this, &Application::consentGiven); dialog->setAttribute(Qt::WA_DeleteOnClose); dialog->show(); } for (QString const& filename : filenames) { addTorrent(filename); } InteropHelper::registerObject(this); #ifdef QT_DBUS_LIB QDBusConnection bus = QDBusConnection::sessionBus(); if (bus.isConnected()) { bus.connect( fdo_notifications_service_name_, fdo_notifications_path_, fdo_notifications_interface_name_, QLatin1String("ActionInvoked"), this, SLOT(onNotificationActionInvoked(quint32, QString))); } #endif } void Application::loadTranslations() { auto const qt_qm_dirs = QStringList() << #if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) QLibraryInfo::path(QLibraryInfo::TranslationsPath) << #else QLibraryInfo::location(QLibraryInfo::TranslationsPath) << #endif #ifdef TRANSLATIONS_DIR QStringLiteral(TRANSLATIONS_DIR) << #endif (applicationDirPath() + QStringLiteral("/translations")); QStringList const app_qm_dirs = QStringList() << #ifdef TRANSLATIONS_DIR QStringLiteral(TRANSLATIONS_DIR) << #endif (applicationDirPath() + QStringLiteral("/translations")); auto const qt_file_name = QStringLiteral("qtbase"); QLocale const locale; QLocale const english_locale(QLocale::English, QLocale::UnitedStates); if (loadTranslation(qt_translator_, qt_file_name, locale, qt_qm_dirs) || loadTranslation(qt_translator_, qt_file_name, english_locale, qt_qm_dirs)) { installTranslator(&qt_translator_); } if (loadTranslation(app_translator_, config_name_, locale, app_qm_dirs) || loadTranslation(app_translator_, config_name_, english_locale, app_qm_dirs)) { installTranslator(&app_translator_); } } void Application::quitLater() const { QTimer::singleShot(0, this, SLOT(quit())); } void Application::onTorrentsEdited(torrent_ids_t const& torrent_ids) const { // the backend's tr_info has changed, so reload those fields session_->initTorrents(torrent_ids); } QStringList Application::getNames(torrent_ids_t const& torrent_ids) const { QStringList names; for (auto const& id : torrent_ids) { names.push_back(model_->getTorrentFromId(id)->name()); } names.sort(); return names; } void Application::onTorrentsAdded(torrent_ids_t const& torrent_ids) const { if (!prefs_->getBool(Prefs::SHOW_NOTIFICATION_ON_ADD)) { return; } for (auto id : torrent_ids) { notifyTorrentAdded(model_->getTorrentFromId(id)); } } void Application::onTorrentsCompleted(torrent_ids_t const& torrent_ids) const { if (prefs_->getBool(Prefs::SHOW_NOTIFICATION_ON_COMPLETE)) { auto const title = tr("Torrent(s) Completed", nullptr, static_cast(std::size(torrent_ids))); auto const body = getNames(torrent_ids).join(QStringLiteral("\n")); notifyApp(title, body); } if (prefs_->getBool(Prefs::COMPLETE_SOUND_ENABLED)) { #if defined(Q_OS_WIN) || defined(Q_OS_MAC) beep(); #else auto args = prefs_->get(Prefs::COMPLETE_SOUND_COMMAND); auto const command = args.takeFirst(); QProcess::execute(command, args); #endif } } void Application::onTorrentsNeedInfo(torrent_ids_t const& torrent_ids) const { if (!torrent_ids.empty()) { session_->initTorrents(torrent_ids); } } void Application::notifyTorrentAdded(Torrent const* tor) const { QStringList actions; actions << QString(QLatin1String("start-now(%1)")).arg(tor->id()) << QObject::tr("Start Now"); notifyApp(tr("Torrent Added"), tor->name(), actions); } /*** **** ***/ void Application::consentGiven(int result) const { if (result == QMessageBox::Ok) { prefs_->set(Prefs::USER_HAS_GIVEN_INFORMED_CONSENT, true); } else { quit(); } } void Application::saveGeometry() const { if (prefs_ != nullptr && window_ != nullptr) { auto const geometry = window_->geometry(); prefs_->set(Prefs::MAIN_WINDOW_HEIGHT, std::max(100, geometry.height())); prefs_->set(Prefs::MAIN_WINDOW_WIDTH, std::max(100, geometry.width())); prefs_->set(Prefs::MAIN_WINDOW_X, geometry.x()); prefs_->set(Prefs::MAIN_WINDOW_Y, geometry.y()); } } /*** **** ***/ void Application::refreshPref(int key) const { switch (key) { case Prefs::BLOCKLIST_UPDATES_ENABLED: maybeUpdateBlocklist(); break; case Prefs::DIR_WATCH: case Prefs::DIR_WATCH_ENABLED: watch_dir_->setPath(prefs_->getString(Prefs::DIR_WATCH), prefs_->getBool(Prefs::DIR_WATCH_ENABLED)); break; default: break; } } void Application::maybeUpdateBlocklist() const { if (!prefs_->getBool(Prefs::BLOCKLIST_UPDATES_ENABLED)) { return; } QDateTime const last_updated_at = prefs_->getDateTime(Prefs::BLOCKLIST_DATE); QDateTime const next_update_at = last_updated_at.addDays(7); QDateTime const now = QDateTime::currentDateTime(); if (now < next_update_at) { session_->updateBlocklist(); prefs_->set(Prefs::BLOCKLIST_DATE, now); } } void Application::onSessionSourceChanged() const { session_->initTorrents(); session_->refreshSessionStats(); session_->refreshSessionInfo(); } void Application::refreshTorrents() { // usually we just poll the torrents that have shown recent activity, // but we also periodically ask for updates on the others to ensure // nothing's falling through the cracks. time_t const now = time(nullptr); if (last_full_update_time_ + 60 >= now) { session_->refreshActiveTorrents(); } else { last_full_update_time_ = now; session_->refreshAllTorrents(); } } /*** **** ***/ void Application::addTorrent(QString const& addme) const { addTorrent(AddData(addme)); } void Application::addTorrent(AddData const& addme) const { if (addme.type == addme.NONE) { return; } if (!prefs_->getBool(Prefs::OPTIONS_PROMPT)) { session_->addTorrent(addme); } else { auto* o = new OptionsDialog(*session_, *prefs_, addme, window_.get()); o->show(); } raise(); } /*** **** ***/ void Application::raise() const { alert(window_.get()); } bool Application::notifyApp(QString const& title, QString const& body, QStringList const& actions) const { #ifdef QT_DBUS_LIB if (auto bus = QDBusConnection::sessionBus(); bus.isConnected()) { QDBusMessage m = QDBusMessage::createMethodCall( fdo_notifications_service_name_, fdo_notifications_path_, fdo_notifications_interface_name_, QStringLiteral("Notify")); QVariantList args; args.append(QStringLiteral("Transmission")); // app_name args.append(0U); // replaces_id args.append(QStringLiteral("transmission")); // icon args.append(title); // summary args.append(body); // body args.append(actions); args.append(QVariantMap({ std::make_pair(QStringLiteral("category"), QVariant(QStringLiteral("transfer.complete"))), })); // hints args.append(static_cast(-1)); // use the default timeout period m.setArguments(args); QDBusReply const reply_msg = bus.call(m); if (reply_msg.isValid() && reply_msg.value() > 0) { return true; } } #endif window_->trayIcon().showMessage(title, body); return true; } #ifdef QT_DBUS_LIB void Application::onNotificationActionInvoked(quint32 /* notification_id */, QString action_key) { auto const match = start_now_regex_.match(action_key); if (match.hasMatch()) { int const torrent_id = match.captured(1).toInt(); session_->startTorrentsNow({ torrent_id }); } } #endif FaviconCache& Application::faviconCache() { return favicons_; } /*** **** ***/ int tr_main(int argc, char** argv) { InteropHelper::initialize(); Application const app(argc, argv); return QApplication::exec(); }