feat: desktop updater (#825)

* added changelog drawer

* Created a scaffold for Linux installation

* implement Linux updating

* Add debug logs about installer in service

* Add client side of installation logic for Windows and MacOS

* Add service side of installation logic for Windows

* ru readme

* Update README_RU.md

* Add files via upload

* chore: added clang-format config files (#1293)

* Update README_RU.md

* Update README.md

* feature: added subscription expiration date for premium v2 (#1261)

* feature: added subscription expiration date for premium v2

* feature: added a check for the presence of the “services” field in the response body of the getServicesList() function

* feature: added prohibition to change location when connection is active

* bugfix: renamed public_key->end_date to public_key->expires_at according to the changes on the backend

* feature/xray user management (#972)

* feature: implement client management functionality for Xray

---------

Co-authored-by: aiamnezia <ai@amnezia.org>
Co-authored-by: vladimir.kuznetsov <nethiuswork@gmail.com>

* Fix formatting

* Add some logs

* Add logs from installattion shell on Windows

* Fix installation for Windows and MacOS

* Optimized code

* Move installer running to client side for Ubuntu

* Move installer launch logic to client side for Windows

* Clean service code

* Add linux_install script to resources

* Add logs for UpdateController

* Add draft for MacOS installation

* Disable updates checking for Android and iOS

* chore: fixed macos update script

* chore: remove duplicate lines

* chore: post merge fixes

* chore: add missing ifdef

* decrease version for testing

* chore: added changelog text processing depend on OS

* add .vscode to .gitignore

* Change updater downloading method to retrieving link from the gateway

* add Release date file creation to s3 deploy script

* Add release date downloading from endpoint

* update check refactoring

* feat: switch macOS auto-update from DMG to ZIP+PKG installer

- Update macOS artifact URL from .dmg to .zip
- Rewrite mac_installer.sh to extract ZIP and install PKG via osascript
- Increase download timeout to 30s for larger ZIP files

* fix: fix Android build

* feat: Change get request for updater link to post

* refactor: preparing NewsModel for update notifications

- Changed `updateModel` to `setNewsList` for better semantic meaning.
- Delegate model container updating to private method updateModel
- Updated the logic for marking news as read to use item IDs instead of a boolean flag.

* feat: Move update notification in news list

- Updated `UpdateController` to handle empty release dates in header text.
- Added `getVersion` method to `UpdateController` for version retrieval.
- Enhanced `NewsModel` to support update notifications with new methods for marking updates as skipped and setting update notifications.
- Updated QML pages to display update information and provide actions for updates and skipping them.
- Introduced `isUpdate` property in `NewsItem` to differentiate between regular news and updates.

* feat: Implement rate limit workaround for gateway requests

- Added a delay before contacting the gateway in both `UpdateController` and `ApiNewsController` to prevent rate limit issues caused by simultaneous requests.

* refactor: Convert synchronous network requests to asynchronous in UpdateController

- Updated `UpdateController` to use asynchronous network requests for fetching gateway URL, version info, changelog, and release date.
- Introduced `doGetAsync` method to handle asynchronous GET requests with error handling.
- Removed synchronous methods to improve responsiveness and prevent blocking the UI during network operations.
- Added a mechanism to prevent multiple concurrent update checks.

* chore: Decrease AmneziaVPN version to 4.8.10.0 in CMakeLists.txt for testing

* refactor: Improve update check handling to avoid rate limit issues

- Updated `CoreController` to initiate update checks after news fetching is complete.
- Removed synchronous waiting in `ApiNewsController` to streamline the fetching process.

* fix: fixed typo in IsReadRole

* fix: fix updater filenames

* chore: move updateController to core

* refactor: update to mvvm

* chore: tiny fix

---------

Co-authored-by: aiamnezia <ai@amnezia.org>
Co-authored-by: aiamnezia <ai@amnezia.com>
Co-authored-by: Pokamest Nikak <pokamest@gmail.com>
Co-authored-by: KsZnak <ksu@amnezia.org>
Co-authored-by: Cyril Anisimov <cyan84@gmail.com>
Co-authored-by: vkamn <vk@amnezia.org>
This commit is contained in:
Nethius
2026-05-04 07:37:19 +03:00
committed by GitHub
parent 396ce23228
commit c28452a5da
25 changed files with 950 additions and 25 deletions

View File

@@ -65,6 +65,7 @@ endif()
qt6_add_resources(QRC ${QRC}
${CMAKE_CURRENT_LIST_DIR}/images/images.qrc
${CMAKE_CURRENT_LIST_DIR}/images/flagKit.qrc
${CMAKE_CURRENT_LIST_DIR}/client_scripts/clientScripts.qrc
${CMAKE_CURRENT_LIST_DIR}/ui/qml/qml.qrc
${CMAKE_CURRENT_LIST_DIR}/server_scripts/serverScripts.qrc
)

View File

@@ -0,0 +1,6 @@
<RCC>
<qresource prefix="/client_scripts">
<file>linux_installer.sh</file>
<file>mac_installer.sh</file>
</qresource>
</RCC>

View File

@@ -0,0 +1,29 @@
#!/bin/bash
EXTRACT_DIR="$1"
INSTALLER_PATH="$2"
# Create and clean extract directory
rm -rf "$EXTRACT_DIR"
mkdir -p "$EXTRACT_DIR"
# Extract TAR archive
tar -xf "$INSTALLER_PATH" -C "$EXTRACT_DIR"
if [ $? -ne 0 ]; then
echo 'Failed to extract TAR archive'
exit 1
fi
# Find and run installer
INSTALLER=$(find "$EXTRACT_DIR" -type f -executable)
if [ -z "$INSTALLER" ]; then
echo 'Installer not found'
exit 1
fi
"$INSTALLER"
EXIT_CODE=$?
# Cleanup
rm -rf "$EXTRACT_DIR"
exit $EXIT_CODE

View File

@@ -0,0 +1,42 @@
#!/bin/bash
EXTRACT_DIR="$1"
INSTALLER_PATH="$2"
set -e
echo "[AmneziaVPN] Installer package: $INSTALLER_PATH"
if [ ! -f "$INSTALLER_PATH" ]; then
echo "[AmneziaVPN] ERROR: Installer package not found: $INSTALLER_PATH"
exit 1
fi
PKG_PATH="$INSTALLER_PATH"
echo "[AmneziaVPN] Using PKG: $PKG_PATH"
# Optional: basic signature/gatekeeper checks (non-fatal)
if command -v pkgutil >/dev/null 2>&1; then
pkgutil --check-signature "$PKG_PATH" || true
fi
if command -v spctl >/dev/null 2>&1; then
spctl -a -vvv -t install "$PKG_PATH" || true
fi
# Run installer with admin privileges via AppleScript (prompts for password)
echo "[AmneziaVPN] Running installer..."
OSA_CMD='do shell script "/usr/sbin/installer -pkg '"$PKG_PATH"' -target /" with administrator privileges'
osascript -e "$OSA_CMD"
STATUS=$?
if [ $STATUS -ne 0 ]; then
echo "[AmneziaVPN] ERROR: installer exited with status $STATUS"
exit $STATUS
fi
echo "[AmneziaVPN] Cleaning up..."
rm -f "$INSTALLER_PATH" || true
rm -rf "$EXTRACT_DIR" 2>/dev/null || true
echo "[AmneziaVPN] Installation completed successfully"
exit 0

View File

@@ -45,6 +45,7 @@ set(HEADERS ${HEADERS}
${CLIENT_ROOT_DIR}/core/controllers/api/servicesCatalogController.h
${CLIENT_ROOT_DIR}/core/controllers/api/subscriptionController.h
${CLIENT_ROOT_DIR}/core/controllers/api/newsController.h
${CLIENT_ROOT_DIR}/core/controllers/updateController.h
${CLIENT_ROOT_DIR}/core/repositories/secureServersRepository.h
${CLIENT_ROOT_DIR}/core/repositories/secureAppSettingsRepository.h
${CLIENT_ROOT_DIR}/core/protocols/qmlRegisterProtocols.h
@@ -119,6 +120,7 @@ set(SOURCES ${SOURCES}
${CLIENT_ROOT_DIR}/core/controllers/api/servicesCatalogController.cpp
${CLIENT_ROOT_DIR}/core/controllers/api/subscriptionController.cpp
${CLIENT_ROOT_DIR}/core/controllers/api/newsController.cpp
${CLIENT_ROOT_DIR}/core/controllers/updateController.cpp
${CLIENT_ROOT_DIR}/core/repositories/secureServersRepository.cpp
${CLIENT_ROOT_DIR}/core/repositories/secureAppSettingsRepository.cpp
${CLIENT_ROOT_DIR}/ui/utils/qAutoStart.cpp

View File

@@ -146,6 +146,7 @@ void CoreController::initCoreControllers()
m_servicesCatalogController = new ServicesCatalogController(m_appSettingsRepository);
m_subscriptionController = new SubscriptionController(m_serversRepository, m_appSettingsRepository);
m_newsController = new NewsController(m_appSettingsRepository, m_serversController);
m_updateController = new UpdateController(m_appSettingsRepository, this);
m_installController = new InstallController(m_serversRepository, m_appSettingsRepository, this);
m_exportController = new ExportController(m_serversRepository, m_appSettingsRepository, this);
@@ -212,6 +213,9 @@ void CoreController::initControllers()
m_apiNewsUiController = new ApiNewsUiController(m_newsModel, m_newsController, this);
setQmlContextProperty("ApiNewsController", m_apiNewsUiController);
m_updateUiController = new UpdateUiController(m_updateController, this);
setQmlContextProperty("UpdateController", m_updateUiController);
}
void CoreController::initAndroidController()

View File

@@ -26,6 +26,7 @@
#include "ui/controllers/ipSplitTunnelingUiController.h"
#include "ui/controllers/systemController.h"
#include "ui/controllers/languageUiController.h"
#include "ui/controllers/updateUiController.h"
#include "ui/controllers/api/servicesCatalogUiController.h"
#include "core/controllers/serversController.h"
@@ -39,6 +40,7 @@
#include "core/controllers/selfhosted/installController.h"
#include "core/controllers/settingsController.h"
#include "core/controllers/connectionController.h"
#include "core/controllers/updateController.h"
#include "core/repositories/secureServersRepository.h"
#include "core/repositories/secureAppSettingsRepository.h"
@@ -159,6 +161,7 @@ private:
AppSplitTunnelingUiController* m_appSplitTunnelingUiController;
AllowedDnsUiController* m_allowedDnsUiController;
LanguageUiController* m_languageUiController;
UpdateUiController* m_updateUiController;
SubscriptionUiController* m_subscriptionUiController;
ApiNewsUiController* m_apiNewsUiController;
@@ -173,6 +176,7 @@ private:
ServicesCatalogController* m_servicesCatalogController;
SubscriptionController* m_subscriptionController;
NewsController* m_newsController;
UpdateController* m_updateController;
InstallController* m_installController;
ExportController* m_exportController;
ConnectionController* m_connectionController;

View File

@@ -20,6 +20,7 @@
#include "ui/controllers/selfhosted/installUiController.h"
#include "ui/controllers/importUiController.h"
#include "ui/controllers/api/subscriptionUiController.h"
#include "ui/controllers/updateUiController.h"
#include "ui/models/serversModel.h"
#include "core/controllers/serversController.h"
#include "core/controllers/ipSplitTunnelingController.h"
@@ -83,6 +84,7 @@ void CoreSignalHandlers::initAllHandlers()
initIosImportHandler();
initIosSettingsHandler();
initNotificationHandler();
initUpdateFoundHandler();
}
void CoreSignalHandlers::initErrorMessagesHandler()
@@ -410,3 +412,19 @@ void CoreSignalHandlers::initNotificationHandler()
#endif
}
void CoreSignalHandlers::initUpdateFoundHandler()
{
#if !defined(Q_OS_ANDROID) && !defined(Q_OS_IOS)
connect(m_coreController->m_apiNewsUiController, &ApiNewsUiController::fetchNewsFinished, m_coreController->m_updateUiController,
&UpdateUiController::checkForUpdates);
connect(m_coreController->m_updateUiController, &UpdateUiController::updateFound, this, [this]() {
const QString version = m_coreController->m_updateUiController->getVersion();
const QString updateId = version.isEmpty() ? QStringLiteral("update") : QStringLiteral("update-%1").arg(version);
m_coreController->m_newsModel->setUpdateNotification(
updateId, m_coreController->m_updateUiController->getHeaderText(), m_coreController->m_updateUiController->getChangelogText());
emit m_coreController->m_pageController->showChangelogDrawer();
});
#endif
}

View File

@@ -40,6 +40,7 @@ private:
void initIosImportHandler();
void initIosSettingsHandler();
void initNotificationHandler();
void initUpdateFoundHandler();
CoreController* m_coreController;
};

View File

@@ -0,0 +1,391 @@
#include "updateController.h"
#include <QNetworkReply>
#include <QVersionNumber>
#include <QUrl>
#include <QJsonDocument>
#include <QJsonObject>
#include <QSysInfo>
#include <QTimer>
#include "amneziaApplication.h"
#include "logger.h"
#include "version.h"
#include "core/controllers/gatewayController.h"
#include "core/utils/constants/apiKeys.h"
#include "core/utils/errorStrings.h"
#include "core/utils/selfhosted/scriptsRegistry.h"
namespace
{
Logger logger("UpdateController");
#if defined(Q_OS_WINDOWS)
const QLatin1String kInstallerRemoteFileNamePattern("AmneziaVPN_%1_x64.exe");
const QString kInstallerLocalPath = QStandardPaths::writableLocation(QStandardPaths::TempLocation) + "/AmneziaVPN_installer.exe";
#elif defined(Q_OS_MACOS)
const QLatin1String kInstallerRemoteFileNamePattern("AmneziaVPN_%1_macos.pkg");
const QString kInstallerLocalPath = QStandardPaths::writableLocation(QStandardPaths::TempLocation) + "/AmneziaVPN.pkg";
#elif defined(Q_OS_LINUX) && !defined(Q_OS_ANDROID)
const QLatin1String kInstallerRemoteFileNamePattern("AmneziaVPN_%1_linux_x64.tar");
const QString kInstallerLocalPath = QStandardPaths::writableLocation(QStandardPaths::TempLocation) + "/AmneziaVPN.tar";
#endif
}
UpdateController::UpdateController(SecureAppSettingsRepository* appSettingsRepository, QObject *parent)
: QObject(parent), m_appSettingsRepository(appSettingsRepository)
{
}
QString UpdateController::getRawChangelogText() const
{
return m_changelogText;
}
QString UpdateController::getReleaseDate() const
{
return m_releaseDate;
}
QString UpdateController::getVersion() const
{
return m_version;
}
void UpdateController::checkForUpdates()
{
if (m_updateCheckRunning || !m_appSettingsRepository) {
return;
}
m_updateCheckRunning = true;
fetchGatewayUrl();
}
void UpdateController::finishUpdateCheck()
{
m_updateCheckRunning = false;
}
void UpdateController::doGetAsync(const QString &endpoint, std::function<void(bool, QByteArray)> onDone)
{
QString fullUrl = m_baseUrl + endpoint;
QNetworkRequest req;
req.setTransferTimeout(7000);
req.setUrl(QUrl(fullUrl));
QNetworkReply *reply = amnApp->networkManager()->get(req);
setupNetworkErrorHandling(reply, endpoint);
QObject::connect(reply, &QNetworkReply::finished, this, [this, reply, endpoint, onDone]() {
const bool ok = (reply->error() == QNetworkReply::NoError);
QByteArray data;
if (ok) {
data = reply->readAll();
} else {
handleNetworkError(reply, endpoint);
}
reply->deleteLater();
onDone(ok, data);
});
}
void UpdateController::fetchGatewayUrl()
{
auto gatewayController = QSharedPointer<GatewayController>::create(m_appSettingsRepository->getGatewayEndpoint(),
m_appSettingsRepository->isDevGatewayEnv(),
7000,
m_appSettingsRepository->isStrictKillSwitchEnabled());
QJsonObject apiPayload;
apiPayload[apiDefs::key::cliVersion] = QString(APP_VERSION);
apiPayload[apiDefs::key::osVersion] = QSysInfo::productType();
apiPayload[apiDefs::key::installationUuid] = m_appSettingsRepository->getInstallationUuid(true);
// Workaround: wait before contacting gateway to avoid rate limit triggered by other requests (news etc.)
QTimer::singleShot(1000, this, [this, gatewayController, apiPayload]() {
gatewayController->postAsync(QStringLiteral("%1v1/updater_endpoint"), apiPayload)
.then(this, [this](QPair<ErrorCode, QByteArray> result) {
auto [err, gatewayResponse] = result;
if (err != ErrorCode::NoError) {
logger.error() << errorString(err);
finishUpdateCheck();
return;
}
QJsonObject gatewayData = QJsonDocument::fromJson(gatewayResponse).object();
QString baseUrl = gatewayData.value("url").toString();
if (baseUrl.endsWith('/')) {
baseUrl.chop(1);
}
m_baseUrl = baseUrl;
fetchVersionInfo();
});
});
}
void UpdateController::fetchVersionInfo()
{
doGetAsync("/VERSION", [this](bool ok, QByteArray data) {
if (!ok) {
finishUpdateCheck();
return;
}
m_version = QString::fromUtf8(data).trimmed();
if (!isNewVersionAvailable()) {
finishUpdateCheck();
return;
}
fetchChangelog();
});
}
void UpdateController::fetchChangelog()
{
doGetAsync("/CHANGELOG", [this](bool ok, QByteArray data) {
if (!ok) {
m_changelogText.clear();
} else {
m_changelogText = QString::fromUtf8(data);
}
fetchReleaseDate();
});
}
void UpdateController::fetchReleaseDate()
{
doGetAsync("/RELEASE_DATE", [this](bool ok, QByteArray data) {
if (ok) {
m_releaseDate = QString::fromUtf8(data).trimmed();
} else {
m_releaseDate = QString();
}
m_downloadUrl = composeDownloadUrl();
emit updateFound();
finishUpdateCheck();
});
}
bool UpdateController::isNewVersionAvailable() const
{
auto currentVersion = QVersionNumber::fromString(QString(APP_VERSION));
auto newVersion = QVersionNumber::fromString(m_version);
return newVersion > currentVersion;
}
void UpdateController::setupNetworkErrorHandling(QNetworkReply* reply, const QString& operation)
{
QObject::connect(reply, &QNetworkReply::errorOccurred, [reply, operation](QNetworkReply::NetworkError error) {
logger.error() << QString("Network error occurred while fetching %1: %2 %3")
.arg(operation, reply->errorString(), QString::number(error));
});
QObject::connect(reply, &QNetworkReply::sslErrors, [operation](const QList<QSslError> &errors) {
QStringList errorStrings;
for (const QSslError &err : errors) {
errorStrings << err.errorString();
}
logger.error() << QString("SSL errors while fetching %1: %2").arg(operation, errorStrings.join("; "));
});
}
void UpdateController::handleNetworkError(QNetworkReply* reply, const QString& operation)
{
if (reply->error() == QNetworkReply::NetworkError::OperationCanceledError
|| reply->error() == QNetworkReply::NetworkError::TimeoutError) {
logger.error() << errorString(ErrorCode::ApiConfigTimeoutError);
} else {
QString err = reply->errorString();
logger.error() << "Network error code:" << QString::number(static_cast<int>(reply->error()));
logger.error() << "Error message:" << err;
logger.error() << "HTTP status:" << reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
logger.error() << errorString(ErrorCode::ApiConfigDownloadError);
}
}
QString UpdateController::composeDownloadUrl() const
{
#if !defined(Q_OS_ANDROID) && !defined(Q_OS_IOS)
const QString fileName = QString(kInstallerRemoteFileNamePattern).arg(m_version);
return m_baseUrl + "/" + fileName;
#else
return QString();
#endif
}
void UpdateController::runInstaller()
{
#if !defined(Q_OS_ANDROID) && !defined(Q_OS_IOS)
if (m_downloadUrl.isEmpty()) {
logger.error() << "Download URL is empty";
return;
}
QNetworkRequest request;
request.setTransferTimeout(30000);
request.setUrl(m_downloadUrl);
QNetworkReply *reply = amnApp->networkManager()->get(request);
QObject::connect(reply, &QNetworkReply::finished, [this, reply]() {
if (reply->error() == QNetworkReply::NoError) {
QFile file(kInstallerLocalPath);
if (!file.open(QIODevice::WriteOnly)) {
logger.error() << "Failed to open installer file for writing:" << kInstallerLocalPath << "Error:" << file.errorString();
reply->deleteLater();
return;
}
if (file.write(reply->readAll()) == -1) {
logger.error() << "Failed to write installer data to file:" << kInstallerLocalPath << "Error:" << file.errorString();
file.close();
reply->deleteLater();
return;
}
file.close();
#if defined(Q_OS_WINDOWS)
runWindowsInstaller(kInstallerLocalPath);
#elif defined(Q_OS_MACOS)
runMacInstaller(kInstallerLocalPath);
#elif defined(Q_OS_LINUX) && !defined(Q_OS_ANDROID)
runLinuxInstaller(kInstallerLocalPath);
#endif
} else {
if (reply->error() == QNetworkReply::NetworkError::OperationCanceledError
|| reply->error() == QNetworkReply::NetworkError::TimeoutError) {
logger.error() << errorString(ErrorCode::ApiConfigTimeoutError);
} else {
QString err = reply->errorString();
logger.error() << QString::fromUtf8(reply->readAll());
logger.error() << "Network error code:" << QString::number(static_cast<int>(reply->error()));
logger.error() << "Error message:" << err;
logger.error() << "HTTP status:" << reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
logger.error() << errorString(ErrorCode::ApiConfigDownloadError);
}
}
reply->deleteLater();
});
#endif
}
#if defined(Q_OS_WINDOWS)
int UpdateController::runWindowsInstaller(const QString &installerPath)
{
qint64 pid;
bool success = QProcess::startDetached(installerPath, QStringList(), QString(), &pid);
if (success) {
logger.info() << "Installation process started with PID:" << pid;
} else {
logger.error() << "Failed to start installation process";
return -1;
}
return 0;
}
#endif
#if defined(Q_OS_MACOS)
int UpdateController::runMacInstaller(const QString &installerPath)
{
// Create temporary directory for extraction
QTemporaryDir extractDir;
extractDir.setAutoRemove(false);
if (!extractDir.isValid()) {
logger.error() << "Failed to create temporary directory";
return -1;
}
logger.info() << "Temporary directory created:" << extractDir.path();
// Create script file in the temporary directory
QString scriptPath = extractDir.path() + "/mac_installer.sh";
QFile scriptFile(scriptPath);
if (!scriptFile.open(QIODevice::WriteOnly)) {
logger.error() << "Failed to create script file";
return -1;
}
// Get script content from registry
QString scriptContent = amnezia::scriptData(amnezia::ClientScriptType::mac_installer);
if (scriptContent.isEmpty()) {
logger.error() << "macOS installer script content is empty";
scriptFile.close();
return -1;
}
scriptFile.write(scriptContent.toUtf8());
scriptFile.close();
logger.info() << "Script file created:" << scriptPath;
// Make script executable
QFile::setPermissions(scriptPath, QFile::permissions(scriptPath) | QFile::ExeUser);
// Start detached process
qint64 pid;
bool success =
QProcess::startDetached("/bin/bash", QStringList() << scriptPath << extractDir.path() << installerPath, extractDir.path(), &pid);
if (success) {
logger.info() << "Installation process started with PID:" << pid;
} else {
logger.error() << "Failed to start installation process";
return -1;
}
return 0;
}
#endif
#if defined(Q_OS_LINUX) && !defined(Q_OS_ANDROID)
int UpdateController::runLinuxInstaller(const QString &installerPath)
{
// Create temporary directory for extraction
QTemporaryDir extractDir;
extractDir.setAutoRemove(false);
if (!extractDir.isValid()) {
logger.error() << "Failed to create temporary directory";
return -1;
}
logger.info() << "Temporary directory created:" << extractDir.path();
// Create script file in the temporary directory
QString scriptPath = extractDir.path() + "/installer.sh";
QFile scriptFile(scriptPath);
if (!scriptFile.open(QIODevice::WriteOnly)) {
logger.error() << "Failed to create script file";
return -1;
}
// Get script content from registry
QString scriptContent = amnezia::scriptData(amnezia::ClientScriptType::linux_installer);
scriptFile.write(scriptContent.toUtf8());
scriptFile.close();
logger.info() << "Script file created:" << scriptPath;
// Make script executable
QFile::setPermissions(scriptPath, QFile::permissions(scriptPath) | QFile::ExeUser);
// Start detached process
qint64 pid;
bool success =
QProcess::startDetached("/bin/bash", QStringList() << scriptPath << extractDir.path() << installerPath, extractDir.path(), &pid);
if (success) {
logger.info() << "Installation process started with PID:" << pid;
} else {
logger.error() << "Failed to start installation process";
return -1;
}
return 0;
}
#endif

View File

@@ -0,0 +1,57 @@
#ifndef UPDATECONTROLLER_H
#define UPDATECONTROLLER_H
#include <functional>
#include <QObject>
#include <QNetworkReply>
#include "core/repositories/secureAppSettingsRepository.h"
class UpdateController : public QObject
{
Q_OBJECT
public:
explicit UpdateController(SecureAppSettingsRepository* appSettingsRepository, QObject *parent = nullptr);
QString getRawChangelogText() const;
QString getReleaseDate() const;
QString getVersion() const;
public slots:
void checkForUpdates();
void runInstaller();
signals:
void updateFound();
private:
void finishUpdateCheck();
void fetchGatewayUrl();
void fetchVersionInfo();
void fetchChangelog();
void fetchReleaseDate();
void doGetAsync(const QString &endpoint, std::function<void(bool, QByteArray)> onDone);
bool isNewVersionAvailable() const;
void setupNetworkErrorHandling(QNetworkReply* reply, const QString& operation);
void handleNetworkError(QNetworkReply* reply, const QString& operation);
QString composeDownloadUrl() const;
SecureAppSettingsRepository* m_appSettingsRepository;
QString m_baseUrl;
QString m_changelogText;
QString m_version;
QString m_releaseDate;
QString m_downloadUrl;
bool m_updateCheckRunning = false;
#if defined(Q_OS_WINDOWS)
int runWindowsInstaller(const QString &installerPath);
#elif defined(Q_OS_MACOS)
int runMacInstaller(const QString &installerPath);
#elif defined(Q_OS_LINUX) && !defined(Q_OS_ANDROID)
int runLinuxInstaller(const QString &installerPath);
#endif
};
#endif // UPDATECONTROLLER_H

View File

@@ -73,6 +73,15 @@ QString amnezia::scriptName(ProtocolScriptType type)
}
}
QString amnezia::scriptName(ClientScriptType type)
{
switch (type) {
case ClientScriptType::linux_installer: return QLatin1String("linux_installer.sh");
case ClientScriptType::mac_installer: return QLatin1String("mac_installer.sh");
default: return QString();
}
}
QString amnezia::scriptData(amnezia::SharedScriptType type)
{
QString fileName = QString(":/server_scripts/%1").arg(amnezia::scriptName(type));
@@ -101,6 +110,22 @@ QString amnezia::scriptData(amnezia::ProtocolScriptType type, DockerContainer co
return data;
}
QString amnezia::scriptData(ClientScriptType type)
{
QString fileName = QString(":/client_scripts/%1").arg(amnezia::scriptName(type));
QFile file(fileName);
if (!file.open(QIODevice::ReadOnly)) {
qDebug() << "Warning: script missing" << fileName;
return "";
}
QByteArray data = file.readAll();
if (data.isEmpty()) {
qDebug() << "Warning: script is empty" << fileName;
}
data.replace("\r", "");
return data;
}
amnezia::ScriptVars amnezia::genBaseVars(const ServerCredentials &credentials,
DockerContainer container,
const QString &primaryDns,

View File

@@ -41,14 +41,21 @@ enum ProtocolScriptType {
xray_template
};
enum ClientScriptType {
// Client-side scripts
linux_installer,
mac_installer
};
QString scriptFolder(DockerContainer container);
QString scriptName(SharedScriptType type);
QString scriptName(ProtocolScriptType type);
QString scriptName(ClientScriptType type);
QString scriptData(SharedScriptType type);
QString scriptData(ProtocolScriptType type, DockerContainer container);
QString scriptData(ClientScriptType type);
ScriptVars genBaseVars(const ServerCredentials &credentials,
DockerContainer container,

View File

@@ -22,7 +22,7 @@ void ApiNewsUiController::fetchNews(bool showError)
return;
}
m_newsModel->updateModel(newsArray);
m_newsModel->setNewsList(newsArray);
emit fetchNewsFinished();
});
}

View File

@@ -167,6 +167,7 @@ signals:
void escapePressed();
void closeTopDrawer();
void showChangelogDrawer();
void imeHeightChanged(int height);
void safeAreaTopMarginChanged();
void safeAreaBottomMarginChanged();

View File

@@ -0,0 +1,84 @@
#include "updateUiController.h"
UpdateUiController::UpdateUiController(UpdateController* updateController, QObject *parent)
: QObject(parent), m_updateController(updateController)
{
if (m_updateController) {
connect(m_updateController, &UpdateController::updateFound, this, &UpdateUiController::updateFound);
}
}
QString UpdateUiController::getHeaderText() const
{
if (!m_updateController) {
return QString();
}
const QString version = m_updateController->getVersion();
const QString releaseDate = m_updateController->getReleaseDate();
if (releaseDate.trimmed().isEmpty()) {
return tr("New version released: %1").arg(version);
}
return tr("New version released: %1 (%2)").arg(version, releaseDate);
}
QString UpdateUiController::getChangelogText() const
{
if (!m_updateController) {
return QString();
}
const QString rawChangelog = m_updateController->getRawChangelogText();
if (rawChangelog.isEmpty()) {
return tr("Failed to load changelog text");
}
QStringList lines = rawChangelog.split("\n");
QStringList filteredChangeLogText;
bool add = false;
QString osSection;
#ifdef Q_OS_WINDOWS
osSection = "### Windows";
#elif defined(Q_OS_MACOS)
osSection = "### macOS";
#elif defined(Q_OS_LINUX) && !defined(Q_OS_ANDROID)
osSection = "### Linux";
#endif
for (const QString &line : lines) {
if (line.startsWith("### General")) {
add = true;
} else if (line.startsWith("### ") && line != osSection) {
add = false;
} else if (line == osSection) {
add = true;
}
if (add) {
filteredChangeLogText.append(line);
}
}
return filteredChangeLogText.join("\n");
}
QString UpdateUiController::getVersion() const
{
return m_updateController ? m_updateController->getVersion() : QString();
}
void UpdateUiController::checkForUpdates()
{
if (m_updateController) {
m_updateController->checkForUpdates();
}
}
void UpdateUiController::runInstaller()
{
if (m_updateController) {
m_updateController->runInstaller();
}
}

View File

@@ -0,0 +1,33 @@
#ifndef UPDATEUICONTROLLER_H
#define UPDATEUICONTROLLER_H
#include <QObject>
#include "core/controllers/updateController.h"
class UpdateUiController : public QObject
{
Q_OBJECT
Q_PROPERTY(QString changelogText READ getChangelogText NOTIFY updateFound)
Q_PROPERTY(QString headerText READ getHeaderText NOTIFY updateFound)
public:
explicit UpdateUiController(UpdateController* updateController, QObject *parent = nullptr);
QString getHeaderText() const;
QString getChangelogText() const;
QString getVersion() const;
public slots:
void checkForUpdates();
void runInstaller();
signals:
void updateFound();
private:
UpdateController* m_updateController;
};
#endif // UPDATEUICONTROLLER_H

View File

@@ -33,8 +33,9 @@ QVariant NewsModel::data(const QModelIndex &index, int role) const
case TitleRole: return item.title;
case ContentRole: return item.content;
case TimestampRole: return item.timestamp.toLocalTime().toString(Qt::ISODate);
case IsReadRole: return item.read;
case IsReadRole: return m_readIds.contains(item.id);
case IsProcessedRole: return index.row() == m_processedIndex;
case IsUpdateRole: return item.isUpdate;
default: return QVariant();
}
}
@@ -48,6 +49,7 @@ QHash<int, QByteArray> NewsModel::roleNames() const
roles[TimestampRole] = "timestamp";
roles[IsReadRole] = "read";
roles[IsProcessedRole] = "isProcessed";
roles[IsUpdateRole] = "isUpdate";
return roles;
}
@@ -55,13 +57,33 @@ void NewsModel::markAsRead(int index)
{
if (index < 0 || index >= m_items.size())
return;
if (!m_items[index].read) {
m_items[index].read = true;
m_readIds.insert(m_items[index].id);
saveReadIds();
QModelIndex idx = createIndex(index, 0);
emit dataChanged(idx, idx, { IsReadRole });
emit hasUnreadChanged();
const QString &itemId = m_items.at(index).id;
if (itemId.isEmpty() || m_readIds.contains(itemId))
return;
m_readIds.insert(itemId);
saveReadIds();
QModelIndex idx = createIndex(index, 0);
emit dataChanged(idx, idx, { IsReadRole });
emit hasUnreadChanged();
}
void NewsModel::markUpdateAsSkipped()
{
if (!m_updateItem.has_value())
return;
const QString updateId = m_updateItem->id;
if (updateId.isEmpty())
return;
for (int i = 0; i < m_items.size(); ++i) {
if (m_items.at(i).id == updateId) {
markAsRead(i);
break;
}
}
}
@@ -78,37 +100,65 @@ void NewsModel::setProcessedIndex(int index)
emit processedIndexChanged(index);
}
void NewsModel::updateModel(const QJsonArray &serverItems)
void NewsModel::setNewsList(const QJsonArray &serverItems)
{
QList<NewsItem> updatedItems;
QVector<NewsItem> updatedItems;
updatedItems.reserve(serverItems.size());
for (const QJsonValue &value : serverItems) {
if (!value.isObject())
continue;
QJsonObject object = value.toObject();
const QJsonObject object = value.toObject();
NewsItem item;
item.id = object.value("id").toString();
if (item.id.isEmpty())
continue;
item.title = object.value("title").toString();
item.content = object.value("content").toString();
item.timestamp = QDateTime::fromString(object.value("timestamp").toString(), Qt::ISODate);
item.read = m_readIds.contains(object.value("id").toString());
item.isUpdate = false;
updatedItems.append(item);
}
m_apiItems = updatedItems;
updateModel();
}
void NewsModel::setUpdateNotification(const QString &id, const QString &title, const QString &content)
{
if (id.isEmpty())
return;
NewsItem updateItem;
updateItem.id = id;
updateItem.title = title;
updateItem.content = content;
updateItem.timestamp = QDateTime::currentDateTimeUtc();
updateItem.isUpdate = true;
m_updateItem = updateItem;
updateModel();
}
void NewsModel::updateModel()
{
beginResetModel();
m_items = updatedItems;
m_items = m_apiItems;
std::sort(m_items.begin(), m_items.end(), [](const NewsItem &a, const NewsItem &b) { return a.timestamp > b.timestamp; });
if (m_updateItem.has_value()) {
m_items.prepend(*m_updateItem);
}
endResetModel();
loadReadIds();
emit hasUnreadChanged();
}
bool NewsModel::hasUnread() const
{
for (const NewsItem &item : m_items) {
if (!item.read)
if (!m_readIds.contains(item.id))
return true;
}
return false;

View File

@@ -7,6 +7,7 @@
#include <QSet>
#include <QString>
#include <QVector>
#include <optional>
struct NewsItem
{
@@ -14,7 +15,7 @@ struct NewsItem
QString title;
QString content;
QDateTime timestamp;
bool read;
bool isUpdate = false;
};
class NewsModel : public QAbstractListModel
@@ -27,17 +28,20 @@ public:
ContentRole,
TimestampRole,
IsReadRole,
IsProcessedRole
IsProcessedRole,
IsUpdateRole
};
explicit NewsModel(class SecureAppSettingsRepository* appSettingsRepository, QObject *parent = nullptr);
Q_INVOKABLE void markAsRead(int index);
Q_INVOKABLE void markUpdateAsSkipped();
Q_PROPERTY(int processedIndex READ processedIndex WRITE setProcessedIndex NOTIFY processedIndexChanged)
Q_PROPERTY(bool hasUnread READ hasUnread NOTIFY hasUnreadChanged)
int processedIndex() const;
void setProcessedIndex(int index);
void updateModel(const QJsonArray &items);
void setNewsList(const QJsonArray &items);
void setUpdateNotification(const QString &id, const QString &title, const QString &content);
bool hasUnread() const;
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
@@ -50,11 +54,14 @@ signals:
private:
QVector<NewsItem> m_items;
QVector<NewsItem> m_apiItems;
std::optional<NewsItem> m_updateItem;
int m_processedIndex = -1;
class SecureAppSettingsRepository* m_appSettingsRepository;
QSet<QString> m_readIds;
void loadReadIds();
void saveReadIds() const;
void updateModel();
};
#endif // NEWSMODEL_H

View File

@@ -0,0 +1,103 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import "../Controls2"
import "../Controls2/TextTypes"
import "../Config"
DrawerType2 {
id: root
anchors.fill: parent
expandedHeight: parent.height * 0.9
expandedStateContent: Item {
implicitHeight: root.expandedHeight
Header2TextType {
id: header
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
anchors.topMargin: 16
anchors.rightMargin: 16
anchors.leftMargin: 16
anchors.bottomMargin: 16
text: UpdateController.headerText
}
FlickableType {
anchors.top: header.bottom
anchors.bottom: updateButton.top
contentHeight: changelog.height + 32
ParagraphTextType {
id: changelog
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
anchors.topMargin: 16
anchors.rightMargin: 16
anchors.leftMargin: 16
anchors.bottomMargin: 16
HoverHandler {
enabled: parent.hoveredLink
cursorShape: Qt.PointingHandCursor
}
onLinkActivated: function(link) {
Qt.openUrlExternally(link)
}
text: UpdateController.changelogText
textFormat: Text.MarkdownText
}
}
BasicButtonType {
id: updateButton
anchors.bottom: skipButton.top
anchors.left: parent.left
anchors.right: parent.right
anchors.bottomMargin: 8
anchors.rightMargin: 16
anchors.leftMargin: 16
text: qsTr("Update")
clickedFunc: function() {
PageController.showBusyIndicator(true)
UpdateController.runInstaller()
PageController.showBusyIndicator(false)
root.closeTriggered()
}
}
BasicButtonType {
id: skipButton
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.right: parent.right
anchors.bottomMargin: 16
anchors.rightMargin: 16
anchors.leftMargin: 16
defaultColor: "transparent"
hoveredColor: Qt.rgba(1, 1, 1, 0.08)
pressedColor: Qt.rgba(1, 1, 1, 0.12)
disabledColor: "#878B91"
textColor: "#D7D8DB"
borderWidth: 1
text: qsTr("Skip")
clickedFunc: function() {
root.closeTriggered()
}
}
}
}

View File

@@ -14,6 +14,7 @@ import SortFilterProxyModel 0.2
PageType {
id: root
property var newsItem
property bool isUpdateItem: newsItem && (newsItem.isUpdate !== undefined ? newsItem.isUpdate : false)
SortFilterProxyModel {
id: proxyNews
@@ -54,7 +55,7 @@ PageType {
Layout.fillWidth: true
Layout.leftMargin: 16
Layout.rightMargin: 16
headerText: newsItem.title
headerText: newsItem ? newsItem.title : ""
}
ParagraphTextType {
@@ -62,9 +63,9 @@ PageType {
Layout.topMargin: 16
Layout.leftMargin: 16
Layout.rightMargin: 16
text: newsItem.content
text: newsItem ? newsItem.content : ""
textFormat: Text.RichText
textFormat: root.isUpdateItem ? Text.MarkdownText : Text.RichText
onLinkActivated: function(link) {
Qt.openUrlExternally(link)
@@ -76,6 +77,47 @@ PageType {
cursorShape: parent.hoveredLink ? Qt.PointingHandCursor : Qt.ArrowCursor
}
}
BasicButtonType {
Layout.fillWidth: true
Layout.leftMargin: 16
Layout.rightMargin: 16
Layout.topMargin: 24
visible: root.isUpdateItem
text: qsTr("Update")
clickedFunc: function() {
if (!root.isUpdateItem)
return
PageController.showBusyIndicator(true)
UpdateController.runInstaller()
PageController.showBusyIndicator(false)
PageController.closePage()
}
}
BasicButtonType {
Layout.fillWidth: true
Layout.leftMargin: 16
Layout.rightMargin: 16
Layout.topMargin: 8
Layout.bottomMargin: 16
visible: root.isUpdateItem
defaultColor: "transparent"
hoveredColor: Qt.rgba(1, 1, 1, 0.08)
pressedColor: Qt.rgba(1, 1, 1, 0.12)
disabledColor: "#878B91"
textColor: "#D7D8DB"
borderWidth: 1
text: qsTr("Skip")
clickedFunc: function() {
if (!root.isUpdateItem)
return
NewsModel.markUpdateAsSkipped()
PageController.closePage()
}
}
}
}
}

View File

@@ -69,8 +69,10 @@ PageType {
rightImageSource: "qrc:/images/controls/chevron-right.svg"
clickedFunction: function() {
if (!isUpdate) {
NewsModel.markAsRead(index)
NewsModel.processedIndex = index
}
NewsModel.processedIndex = index
PageController.goToPage(PageEnum.PageSettingsNewsDetail)
}
}

View File

@@ -144,6 +144,10 @@ Window {
busyIndicator.visible = visible
PageController.disableControls(visible)
}
function onShowChangelogDrawer() {
changelogDrawer.openTriggered()
}
}
Connections {
@@ -360,4 +364,14 @@ Window {
onAccepted: SystemController.fileDialogClosed(true)
onRejected: SystemController.fileDialogClosed(false)
}
Item {
anchors.fill: parent
ChangelogDrawer {
id: changelogDrawer
anchors.fill: parent
}
}
}

View File

@@ -7,6 +7,7 @@
<file>Components/HomeContainersListView.qml</file>
<file>Components/HomeSplitTunnelingDrawer.qml</file>
<file>Components/InstalledAppsDrawer.qml</file>
<file>Components/ChangelogDrawer.qml</file>
<file>Components/QuestionDrawer.qml</file>
<file>Components/SelectLanguageDrawer.qml</file>
<file>Components/ServersListView.qml</file>

View File

@@ -12,7 +12,8 @@ mkdir -p dist
cd dist
echo $VERSION >> VERSION
echo $VERSION > VERSION
curl -s https://api.github.com/repos/amnezia-vpn/amnezia-client/releases/tags/$VERSION | jq -r .published_at > RELEASE_DATE
curl -s https://api.github.com/repos/amnezia-vpn/amnezia-client/releases/tags/$VERSION | jq -r .body | tr -d '\r' > CHANGELOG
curl -s https://api.github.com/repos/amnezia-vpn/amnezia-client/releases/tags/$VERSION | jq -r .published_at > RELEASE_DATE