diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt index 7f3336d6..f49ae273 100644 --- a/linux/CMakeLists.txt +++ b/linux/CMakeLists.txt @@ -4,6 +4,9 @@ project(linux VERSION 0.1 LANGUAGES CXX) set(CMAKE_CXX_STANDARD_REQUIRED ON) +# Pass version to C++ code +add_compile_definitions(LIBREPODS_VERSION="${PROJECT_VERSION}") + find_package(Qt6 REQUIRED COMPONENTS Quick Widgets Bluetooth DBus) find_package(OpenSSL REQUIRED) find_package(PkgConfig REQUIRED) @@ -13,6 +16,8 @@ qt_standard_project_setup() qt_add_executable(librepods main.cpp + cli.cpp + cli.h logger.h media/mediacontroller.cpp media/mediacontroller.h diff --git a/linux/cli.cpp b/linux/cli.cpp new file mode 100644 index 00000000..471440b6 --- /dev/null +++ b/linux/cli.cpp @@ -0,0 +1,236 @@ +#include "cli.h" + +#include +#include +#include +#include + +#ifndef LIBREPODS_VERSION +#define LIBREPODS_VERSION "0.1" +#endif + +namespace CLI { + +QString noiseControlModeName(NoiseControlMode mode) { + switch (mode) { + case NoiseControlMode::Off: return "off"; + case NoiseControlMode::NoiseCancellation: return "noise-cancellation"; + case NoiseControlMode::Transparency: return "transparency"; + case NoiseControlMode::Adaptive: return "adaptive"; + default: return "unknown"; + } +} + +std::optional parseNoiseControlMode(const QString &name) { + QString lower = name.toLower(); + if (lower == "off" || lower == "0") return NoiseControlMode::Off; + if (lower == "noise-cancellation" || lower == "nc" || lower == "anc" || lower == "1") return NoiseControlMode::NoiseCancellation; + if (lower == "transparency" || lower == "tr" || lower == "2") return NoiseControlMode::Transparency; + if (lower == "adaptive" || lower == "3") return NoiseControlMode::Adaptive; + return std::nullopt; +} + +bool isInstanceRunning() { + QLocalSocket socket; + socket.connectToServer("app_server"); + bool running = socket.waitForConnected(300); + socket.disconnectFromServer(); + return running; +} + +QString sendIpcCommand(const QString &command, int timeout) { + QLocalSocket socket; + socket.connectToServer("app_server"); + + if (!socket.waitForConnected(500)) { + return QString(); + } + + socket.write(command.toUtf8()); + socket.flush(); + socket.waitForBytesWritten(500); + + if (socket.waitForReadyRead(timeout)) { + QString response = QString::fromUtf8(socket.readAll()); + socket.disconnectFromServer(); + return response; + } + + socket.disconnectFromServer(); + return QString(); +} + +int handleCLICommands(QApplication &app) { + app.setApplicationName("LibrePods"); + app.setApplicationVersion(LIBREPODS_VERSION); + + QCommandLineParser parser; + parser.setApplicationDescription("LibrePods - Control your AirPods on Linux"); + + // Standard options + parser.addHelpOption(); + parser.addVersionOption(); + + // Application options + QCommandLineOption debugOption(QStringList() << "debug", + "Enable debug logging output"); + parser.addOption(debugOption); + + QCommandLineOption hideOption(QStringList() << "hide", + "Start with window hidden (tray only)"); + parser.addOption(hideOption); + + // CLI query options + QCommandLineOption statusOption(QStringList() << "s" << "status", + "Show AirPods connection status and battery levels"); + parser.addOption(statusOption); + + QCommandLineOption jsonOption(QStringList() << "j" << "json", + "Output in JSON format (use with --status)"); + parser.addOption(jsonOption); + + QCommandLineOption waybarOption(QStringList() << "w" << "waybar", + "Output in Waybar custom module format"); + parser.addOption(waybarOption); + + // CLI control options + QCommandLineOption setNoiseModeOption(QStringList() << "set-noise-mode", + "Set noise control mode (off, transparency, noise-cancellation/nc/anc, adaptive)", + "mode"); + parser.addOption(setNoiseModeOption); + + QCommandLineOption setCAOption(QStringList() << "set-conversational-awareness", + "Set conversational awareness (on/off, true/false, 1/0)", + "state"); + parser.addOption(setCAOption); + + QCommandLineOption setAdaptiveLevelOption(QStringList() << "set-adaptive-level", + "Set adaptive noise level (0-100)", + "level"); + parser.addOption(setAdaptiveLevelOption); + + parser.process(app); + + bool wantsStatus = parser.isSet(statusOption); + bool wantsJson = parser.isSet(jsonOption); + bool wantsWaybar = parser.isSet(waybarOption); + QString noiseMode = parser.value(setNoiseModeOption); + QString caState = parser.value(setCAOption); + QString adaptiveLevel = parser.value(setAdaptiveLevelOption); + + // Check if this is a CLI command + bool hasStatusQuery = wantsStatus || wantsWaybar; + bool hasControlCommand = !noiseMode.isEmpty() || !caState.isEmpty() || !adaptiveLevel.isEmpty(); + bool isCLICommand = hasStatusQuery || hasControlCommand; + + if (!isCLICommand) { + // Not a CLI command, return -1 to indicate GUI should start + return -1; + } + + // Handle CLI commands + QTextStream out(stdout); + QTextStream err(stderr); + + if (!isInstanceRunning()) { + err << "Error: LibrePods is not running. Start the application first.\n"; + return 1; + } + + // Handle waybar output + if (wantsWaybar) { + QString response = sendIpcCommand("cli:status:waybar"); + + if (response.isEmpty()) { + // Output disconnected state for waybar + out << R"({"text": "󰥰 --", "tooltip": "LibrePods not running", "class": "disconnected"})" << "\n"; + return 0; + } + + out << response; + if (!response.endsWith('\n')) out << "\n"; + return 0; + } + + // Handle status query + if (wantsStatus) { + QString cmd = wantsJson ? "cli:status:json" : "cli:status:text"; + QString response = sendIpcCommand(cmd); + + if (response.isEmpty()) { + err << "Error: No response from LibrePods\n"; + return 1; + } + + out << response; + if (!response.endsWith('\n')) out << "\n"; + return 0; + } + + // Handle set noise mode + if (!noiseMode.isEmpty()) { + auto mode = parseNoiseControlMode(noiseMode); + if (!mode.has_value()) { + err << "Error: Invalid noise mode '" << noiseMode << "'\n"; + err << "Valid modes: off, transparency, noise-cancellation (nc/anc), adaptive\n"; + return 1; + } + + QString response = sendIpcCommand("cli:set-noise-mode:" + QString::number(static_cast(mode.value()))); + if (response.startsWith("OK")) { + out << "Noise control mode set to: " << noiseControlModeName(mode.value()) << "\n"; + return 0; + } else { + err << "Error: " << (response.isEmpty() ? "No response from LibrePods" : response) << "\n"; + return 1; + } + } + + // Handle set conversational awareness + if (!caState.isEmpty()) { + QString lower = caState.toLower(); + bool enabled; + if (lower == "on" || lower == "true" || lower == "1" || lower == "yes") { + enabled = true; + } else if (lower == "off" || lower == "false" || lower == "0" || lower == "no") { + enabled = false; + } else { + err << "Error: Invalid state '" << caState << "'\n"; + err << "Valid values: on/off, true/false, 1/0, yes/no\n"; + return 1; + } + + QString response = sendIpcCommand("cli:set-ca:" + QString(enabled ? "1" : "0")); + if (response.startsWith("OK")) { + out << "Conversational awareness set to: " << (enabled ? "on" : "off") << "\n"; + return 0; + } else { + err << "Error: " << (response.isEmpty() ? "No response from LibrePods" : response) << "\n"; + return 1; + } + } + + // Handle set adaptive level + if (!adaptiveLevel.isEmpty()) { + bool ok; + int level = adaptiveLevel.toInt(&ok); + if (!ok || level < 0 || level > 100) { + err << "Error: Invalid adaptive level '" << adaptiveLevel << "'\n"; + err << "Valid range: 0-100\n"; + return 1; + } + + QString response = sendIpcCommand("cli:set-adaptive-level:" + QString::number(level)); + if (response.startsWith("OK")) { + out << "Adaptive noise level set to: " << level << "\n"; + return 0; + } else { + err << "Error: " << (response.isEmpty() ? "No response from LibrePods" : response) << "\n"; + return 1; + } + } + + return 0; +} + +} // namespace CLI diff --git a/linux/cli.h b/linux/cli.h new file mode 100644 index 00000000..e75b5535 --- /dev/null +++ b/linux/cli.h @@ -0,0 +1,26 @@ +#pragma once + +#include +#include +#include +#include "enums.h" + +using namespace AirpodsTrayApp::Enums; + +namespace CLI { + +// Noise control mode helpers +QString noiseControlModeName(NoiseControlMode mode); +std::optional parseNoiseControlMode(const QString &name); + +// Check if another instance is running +bool isInstanceRunning(); + +// Send IPC command to running instance and get response +QString sendIpcCommand(const QString &command, int timeout = 2000); + +// Parse CLI arguments and handle CLI commands +// Returns: -1 if should continue to GUI, otherwise the exit code +int handleCLICommands(QApplication &app); + +} // namespace CLI diff --git a/linux/main.cpp b/linux/main.cpp index 63456f7f..4c1fa3fe 100644 --- a/linux/main.cpp +++ b/linux/main.cpp @@ -12,6 +12,9 @@ #include #include #include +#include +#include +#include #include "airpods_packets.h" #include "logger.h" @@ -26,6 +29,7 @@ #include "ble/bleutils.h" #include "QRCodeImageProvider.hpp" #include "systemsleepmonitor.hpp" +#include "cli.h" using namespace AirpodsTrayApp::Enums; @@ -134,6 +138,109 @@ class AirPodsTrayApp : public QObject { QString phoneMacStatus() const { return m_phoneMacStatus; } bool hearingAidEnabled() const { return m_deviceInfo->hearingAidEnabled(); } + QString getStatusJson() const { + QJsonObject status; + status["connected"] = areAirpodsConnected(); + + if (areAirpodsConnected() && m_deviceInfo) { + status["device_name"] = m_deviceInfo->deviceName(); + status["model"] = m_deviceInfo->modelNumber(); + status["bluetooth_address"] = m_deviceInfo->bluetoothAddress(); + + QJsonObject battery; + Battery *bat = m_deviceInfo->getBattery(); + if (bat) { + battery["left"] = bat->getLeftPodLevel(); + battery["right"] = bat->getRightPodLevel(); + battery["case"] = bat->getCaseLevel(); + battery["left_charging"] = bat->isLeftPodCharging(); + battery["right_charging"] = bat->isRightPodCharging(); + battery["case_charging"] = bat->isCaseCharging(); + } + status["battery"] = battery; + + status["noise_control_mode"] = static_cast(m_deviceInfo->noiseControlMode()); + status["noise_control_mode_name"] = CLI::noiseControlModeName(m_deviceInfo->noiseControlMode()); + status["conversational_awareness"] = m_deviceInfo->conversationalAwareness(); + status["adaptive_noise_level"] = m_deviceInfo->adaptiveNoiseLevel(); + status["one_bud_anc_mode"] = m_deviceInfo->oneBudANCMode(); + status["hearing_aid_enabled"] = m_deviceInfo->hearingAidEnabled(); + + QJsonObject ear; + EarDetection *earDet = m_deviceInfo->getEarDetection(); + if (earDet) { + ear["primary_in_ear"] = earDet->isPrimaryInEar(); + ear["secondary_in_ear"] = earDet->isSecondaryInEar(); + } + status["ear_detection"] = ear; + } + + QJsonDocument doc(status); + return doc.toJson(QJsonDocument::Compact); + } + + QString getStatusText() const { + if (!areAirpodsConnected() || !m_deviceInfo) { + return "Status: Not connected\n"; + } + + QString text; + QTextStream out(&text); + + out << "Status: Connected\n"; + out << "Device: " << m_deviceInfo->deviceName() << "\n"; + out << "Model: " << m_deviceInfo->modelNumber() << "\n"; + out << "Address: " << m_deviceInfo->bluetoothAddress() << "\n"; + + Battery *bat = m_deviceInfo->getBattery(); + if (bat) { + out << "Battery:\n"; + out << " Left: " << bat->getLeftPodLevel() << "%" << (bat->isLeftPodCharging() ? " (charging)" : "") << "\n"; + out << " Right: " << bat->getRightPodLevel() << "%" << (bat->isRightPodCharging() ? " (charging)" : "") << "\n"; + out << " Case: " << bat->getCaseLevel() << "%" << (bat->isCaseCharging() ? " (charging)" : "") << "\n"; + } + + out << "Noise Control: " << CLI::noiseControlModeName(m_deviceInfo->noiseControlMode()) << "\n"; + out << "Conversational Awareness: " << (m_deviceInfo->conversationalAwareness() ? "On" : "Off") << "\n"; + out << "Adaptive Noise Level: " << m_deviceInfo->adaptiveNoiseLevel() << "\n"; + + return text; + } + + QString getStatusWaybar() const { + QJsonObject waybar; + + if (!areAirpodsConnected() || !m_deviceInfo) { + waybar["text"] = QString::fromUtf8("󰥰 --"); + waybar["tooltip"] = "AirPods disconnected"; + waybar["class"] = "disconnected"; + } else { + Battery *bat = m_deviceInfo->getBattery(); + int left = bat ? bat->getLeftPodLevel() : 0; + int right = bat ? bat->getRightPodLevel() : 0; + int avg = (left + right) / 2; + + waybar["text"] = QString::fromUtf8("󰥰 %1%").arg(avg); + + QString tooltip; + QTextStream ts(&tooltip); + ts << m_deviceInfo->deviceName() << "\n"; + ts << "Left: " << left << "%"; + if (bat && bat->isLeftPodCharging()) ts << " ⚡"; + ts << "\n"; + ts << "Right: " << right << "%"; + if (bat && bat->isRightPodCharging()) ts << " ⚡"; + ts << "\n"; + ts << "Mode: " << CLI::noiseControlModeName(m_deviceInfo->noiseControlMode()); + + waybar["tooltip"] = tooltip; + waybar["class"] = "connected"; + } + + QJsonDocument doc(waybar); + return doc.toJson(QJsonDocument::Compact); + } + private: bool debugMode; bool isConnectedLocally = false; @@ -440,8 +547,12 @@ public slots: private slots: void onTrayIconActivated() { - QQuickWindow *window = qobject_cast( - QGuiApplication::topLevelWindows().constFirst()); + const auto windows = QGuiApplication::topLevelWindows(); + if (windows.isEmpty()) { + loadMainModule(); + return; + } + QQuickWindow *window = qobject_cast(windows.constFirst()); if (window) { window->show(); @@ -452,26 +563,26 @@ private slots: void onOpenApp() { + if (parent->rootObjects().isEmpty()) { + loadMainModule(); + return; + } QObject *rootObject = parent->rootObjects().first(); if (rootObject) { QMetaObject::invokeMethod(rootObject, "reopen", Q_ARG(QVariant, "app")); } - else - { - loadMainModule(); - } } void onOpenSettings() { + if (parent->rootObjects().isEmpty()) { + loadMainModule(); + return; + } QObject *rootObject = parent->rootObjects().first(); if (rootObject) { QMetaObject::invokeMethod(rootObject, "reopen", Q_ARG(QVariant, "settings")); } - else - { - loadMainModule(); - } } void sendHandshake() { @@ -985,39 +1096,47 @@ private slots: }; int main(int argc, char *argv[]) { - QApplication app(argc, argv); + // Use Fusion style as fallback for missing theme modules + if (qgetenv("QT_STYLE_OVERRIDE").toLower() == "kvantum") + qputenv("QT_STYLE_OVERRIDE", "Fusion"); + qputenv("QT_QUICK_CONTROLS_STYLE", "Fusion"); - QLocalServer::removeServer("app_server"); - - QFile stale("/tmp/app_server"); - if (stale.exists()) - stale.remove(); + QApplication app(argc, argv); - QLocalSocket socket_check; - socket_check.connectToServer("app_server"); + // Handle CLI commands first (returns -1 if should continue to GUI) + int cliResult = CLI::handleCLICommands(app); + if (cliResult >= 0) { + return cliResult; + } - if (socket_check.waitForConnected(300)) { + // Check if another instance is running + if (CLI::isInstanceRunning()) { LOG_INFO("Another instance already running! Reopening window..."); - socket_check.write("reopen"); - socket_check.flush(); - socket_check.waitForBytesWritten(200); - socket_check.disconnectFromServer(); + QLocalSocket reopen_socket; + reopen_socket.connectToServer("app_server"); + if (reopen_socket.waitForConnected(300)) { + reopen_socket.write("reopen"); + reopen_socket.flush(); + reopen_socket.waitForBytesWritten(200); + reopen_socket.disconnectFromServer(); + } return 0; } - app.setDesktopFileName("me.kavishdevar.librepods"); - app.setQuitOnLastWindowClosed(false); - bool debugMode = false; - bool hideOnStart = false; - for (int i = 1; i < argc; ++i) { - if (QString(argv[i]) == "--debug") - debugMode = true; + // Clean up stale socket files before starting new instance + QLocalServer::removeServer("app_server"); + QFile stale("/tmp/app_server"); + if (stale.exists()) + stale.remove(); - if (QString(argv[i]) == "--hide") - hideOnStart = true; - } + // Parse --debug and --hide flags for GUI mode + bool debugMode = app.arguments().contains("--debug"); + bool hideOnStart = app.arguments().contains("--hide"); + + app.setDesktopFileName("me.kavishdevar.librepods"); + app.setQuitOnLastWindowClosed(false); QQmlApplicationEngine engine; qmlRegisterType("me.kavishdevar.Battery", 1, 0, "Battery"); @@ -1049,21 +1168,86 @@ int main(int argc, char *argv[]) { { LOG_DEBUG("Server started, waiting for connections..."); } + QObject::connect(&server, &QLocalServer::newConnection, [&]() { QLocalSocket* socket = server.nextPendingConnection(); - // Handles Proper Connection - QObject::connect(socket, &QLocalSocket::readyRead, [socket, &engine, &trayApp]() { - QString msg = socket->readAll(); - // Check if the message is "reopen", if so, trigger onOpenApp function + QObject::connect(socket, &QLocalSocket::disconnected, socket, &QLocalSocket::deleteLater); + + QObject::connect(socket, &QLocalSocket::readyRead, [socket, &engine, trayApp]() { + QString msg = QString::fromUtf8(socket->readAll()); + LOG_DEBUG("IPC message received: " << msg); + + // Handle CLI commands + if (msg.startsWith("cli:")) { + QString response; + + if (msg == "cli:status:json") { + response = trayApp->getStatusJson(); + } + else if (msg == "cli:status:text") { + response = trayApp->getStatusText(); + } + else if (msg == "cli:status:waybar") { + response = trayApp->getStatusWaybar(); + } + else if (msg.startsWith("cli:set-noise-mode:")) { + QString modeStr = msg.mid(QString("cli:set-noise-mode:").length()); + bool ok; + int mode = modeStr.toInt(&ok); + if (!ok || mode < 0 || mode > 3) { + response = "Error: Invalid noise mode"; + } else if (!trayApp->areAirpodsConnected()) { + response = "Error: AirPods not connected"; + } else { + trayApp->setNoiseControlModeInt(mode); + response = "OK"; + } + } + else if (msg.startsWith("cli:set-ca:")) { + QString stateStr = msg.mid(QString("cli:set-ca:").length()); + if (stateStr != "0" && stateStr != "1") { + response = "Error: Invalid state"; + } else if (!trayApp->areAirpodsConnected()) { + response = "Error: AirPods not connected"; + } else { + trayApp->setConversationalAwareness(stateStr == "1"); + response = "OK"; + } + } + else if (msg.startsWith("cli:set-adaptive-level:")) { + QString levelStr = msg.mid(QString("cli:set-adaptive-level:").length()); + bool ok; + int level = levelStr.toInt(&ok); + if (!ok || level < 0 || level > 100) { + response = "Error: Invalid level (0-100)"; + } else if (!trayApp->areAirpodsConnected()) { + response = "Error: AirPods not connected"; + } else { + trayApp->setAdaptiveNoiseLevel(level); + response = "OK"; + } + } + else { + response = "Error: Unknown command"; + } + + socket->write(response.toUtf8()); + socket->flush(); + socket->waitForBytesWritten(500); + socket->disconnectFromServer(); + return; + } + + // Handle reopen command if (msg == "reopen") { LOG_INFO("Reopening app window"); - QObject *rootObject = engine.rootObjects().first(); - if (rootObject) { - QMetaObject::invokeMethod(rootObject, "reopen", Q_ARG(QVariant, "app")); - } - else - { + if (engine.rootObjects().isEmpty()) { trayApp->loadMainModule(); + } else { + QObject *rootObject = engine.rootObjects().first(); + if (rootObject) { + QMetaObject::invokeMethod(rootObject, "reopen", Q_ARG(QVariant, "app")); + } } } else @@ -1072,13 +1256,12 @@ int main(int argc, char *argv[]) { } socket->disconnectFromServer(); }); - // Handles connection errors + QObject::connect(socket, &QLocalSocket::errorOccurred, [socket]() { LOG_ERROR("Failed to connect to the duplicate app instance"); LOG_DEBUG("Connection error: " << socket->errorString()); }); - // Handle server-level errors QObject::connect(&server, &QLocalServer::serverError, [&]() { LOG_ERROR("Server failed to accept a new connection"); LOG_DEBUG("Server error: " << server.errorString()); @@ -1097,6 +1280,7 @@ int main(int argc, char *argv[]) { if (stale.exists()) stale.remove(); }); + return app.exec(); }