2026-07-10 02:45:13 +00:00
#include "UpdaterLogic.h"
#include <QSet>
#include <QDebug>
2026-07-14 01:37:06 +00:00
#include <QFileInfo>
#include <QFile>
#include <QCoreApplication>
2026-07-10 02:45:13 +00:00
#include <QEventLoop>
#include <QElapsedTimer>
#include <QThread>
#include <QNetworkRequest>
#include <QNetworkProxy>
#include <QJsonArray>
#include <QCryptographicHash>
#include <QDir>
#include <QJsonDocument>
#include <QSaveFile>
#include <QApplication>
#include <algorithm>
#include "ConfigHelper.h"
#ifdef HAVE_OPENSSL
#include <openssl/pem.h>
#include <openssl/evp.h>
#include <openssl/bio.h>
#include <openssl/err.h>
#endif
UpdaterLogic :: UpdaterLogic ( QObject * parent )
: QObject ( parent )
{
m_serverAddr = ConfigHelper :: instance (). getValue ( "Server" , "api_base_url" );
}
void UpdaterLogic :: getManifest ( const QString & appId , const QString & channel , const QString & targetVer , int versionId )
{
QString url = m_serverAddr + "/api/v1/update/manifest" ;
QJsonObject body ;
body [ "app_id" ] = appId ;
body [ "channel" ] = channel ;
body [ "version" ] = targetVer ;
body [ "version_id" ] = versionId ;
m_http . postRequest ( url , body , [ this ]( int code , const QJsonObject & resp )
{
qDebug () << "Manifest API returned code:" << code ;
m_manifest = QJsonObject ();
m_manifestText . clear ();
if ( code == 200 )
{
if ( resp . contains ( "manifest_text" ) && resp . contains ( "manifest" ))
{
m_manifestText = resp [ "manifest_text" ]. toString ();
m_manifest = resp [ "manifest" ]. toObject ();
qDebug () << "Received manifest version:" << m_manifest . value ( "version" ). toString ();
m_fileItems . clear ();
QJsonArray files = m_manifest . value ( "files" ). toArray ();
for ( const QJsonValue & fileItem : files )
{
QJsonObject fileObj = fileItem . toObject ();
FileDownloadItem fi ;
fi . path = fileObj . value ( "path" ). toString ();
fi . sha256 = fileObj . value ( "sha256" ). toString ();
fi . size = fileObj . value ( "size" ). toVariant (). toLongLong ();
fi . url = m_serverAddr + "/api/v1/update/file/" + fi . path ; // placeholder, actual download URL uses download-url or signed object URL
m_fileItems . append ( fi );
}
}
else
{
qDebug () << "Manifest response missing fields" ;
}
}
else
{
qDebug () << "Failed to get manifest" ;
}
emit fetchUrlFinished ();
});
}
bool UpdaterLogic :: verifySignature ( const QByteArray & payload , const QString & signatureBase64 , const QString & publicKeyPath ) const
{
#ifndef HAVE_OPENSSL
Q_UNUSED ( payload );
Q_UNUSED ( signatureBase64 );
Q_UNUSED ( publicKeyPath );
qDebug () << "OpenSSL not available, cannot verify manifest signature" ;
return false ;
#else
QFile keyFile ( publicKeyPath );
if ( ! keyFile . open ( QIODevice :: ReadOnly ))
{
qDebug () << "Cannot open public key file:" << publicKeyPath ;
return false ;
}
QByteArray keyData = keyFile . readAll ();
keyFile . close ();
BIO * bio = BIO_new_mem_buf ( keyData . constData (), keyData . size ());
if ( ! bio )
{
qDebug () << "BIO_new_mem_buf failed" ;
return false ;
}
EVP_PKEY * pkey = PEM_read_bio_PUBKEY ( bio , NULL , NULL , NULL );
BIO_free ( bio );
if ( ! pkey )
{
qDebug () << "PEM_read_bio_PUBKEY failed" ;
return false ;
}
QByteArray signature = QByteArray :: fromBase64 ( signatureBase64 . toUtf8 ());
EVP_MD_CTX * mdctx = EVP_MD_CTX_new ();
if ( ! mdctx )
{
EVP_PKEY_free ( pkey );
qDebug () << "EVP_MD_CTX_new failed" ;
return false ;
}
bool ok = false ;
if ( EVP_DigestVerifyInit ( mdctx , NULL , EVP_sha256 (), NULL , pkey ) == 1 )
{
if ( EVP_DigestVerifyUpdate ( mdctx , payload . constData (), payload . size ()) == 1 )
{
if ( EVP_DigestVerifyFinal ( mdctx , reinterpret_cast < const unsigned char *> ( signature . constData ()), signature . size ()) == 1 )
{
ok = true ;
}
}
}
EVP_MD_CTX_free ( mdctx );
EVP_PKEY_free ( pkey );
if ( ! ok )
{
qDebug () << "Manifest signature verification failed" ;
}
return ok ;
#endif
}
bool UpdaterLogic :: verifyManifestSignature ( const QString & publicKeyPath ) const
{
if ( m_manifest . isEmpty () || m_manifestText . isEmpty ())
{
qDebug () << "No manifest available to verify" ;
return false ;
}
QString signature = m_manifest . value ( "signature" ). toString ();
if ( signature . isEmpty ())
{
qDebug () << "Manifest signature empty" ;
return false ;
}
QString path = publicKeyPath ;
if ( ! QFile :: exists ( path ))
{
path = QApplication :: applicationDirPath () + "/" + publicKeyPath ;
}
return verifySignature ( m_manifestText . toUtf8 (), signature , path );
}
bool UpdaterLogic :: saveManifestCache ( const QString & cacheDir ) const
{
if ( m_manifest . isEmpty ())
return false ;
QDir dir ( cacheDir );
if ( ! dir . exists () && ! dir . mkpath ( "." ))
return false ;
QString version = m_manifest . value ( "version" ). toString ();
QString filePath = cacheDir + "/manifest_" + version + ".json" ;
QFile file ( filePath );
if ( ! file . open ( QIODevice :: WriteOnly | QIODevice :: Truncate ))
return false ;
QJsonObject wrapper ;
wrapper [ "manifest" ] = m_manifest ;
wrapper [ "manifest_text" ] = m_manifestText ;
QJsonDocument doc ( wrapper );
file . write ( doc . toJson ( QJsonDocument :: Indented ));
file . close ();
qDebug () << "Manifest cached to" << filePath ;
return true ;
}
bool UpdaterLogic :: loadManifestCache ( const QString & cacheDir , const QString & version )
{
QString filePath = cacheDir + "/manifest_" + version + ".json" ;
QFile file ( filePath );
if ( ! file . exists () || ! file . open ( QIODevice :: ReadOnly ))
return false ;
QByteArray raw = file . readAll ();
file . close ();
QJsonDocument doc = QJsonDocument :: fromJson ( raw );
if ( ! doc . isObject ())
return false ;
QJsonObject wrapper = doc . object ();
if ( ! wrapper . contains ( "manifest" ) || ! wrapper . contains ( "manifest_text" ))
return false ;
m_manifest = wrapper [ "manifest" ]. toObject ();
m_manifestText = wrapper [ "manifest_text" ]. toString ();
qDebug () << "Loaded cached manifest" << version ;
return true ;
}
QByteArray UpdaterLogic :: canonicalManifestBytes ( const QJsonObject & manifest ) const
{
QJsonObject copy = manifest ;
copy . remove ( "signature" );
auto sortObject = [ & ]( auto && self , const QJsonObject & obj ) -> QJsonObject {
QStringList keys = obj . keys ();
std :: sort ( keys . begin (), keys . end (), std :: less < QString > ());
QJsonObject sorted ;
for ( const auto & key : keys )
{
QJsonValue value = obj . value ( key );
if ( value . isObject ())
sorted . insert ( key , self ( self , value . toObject ()));
else if ( value . isArray ())
{
QJsonArray sortedArray ;
for ( const auto & element : value . toArray ())
{
if ( element . isObject ())
sortedArray . append ( self ( self , element . toObject ()));
else
sortedArray . append ( element );
}
sorted . insert ( key , sortedArray );
}
else
{
sorted . insert ( key , value );
}
}
return sorted ;
};
QJsonObject sorted = sortObject ( sortObject , copy );
QJsonDocument doc ( sorted );
return doc . toJson ( QJsonDocument :: Compact );
}
QStringList UpdaterLogic :: obsoleteFilesComparedTo ( const QJsonObject & oldManifest ) const
{
QSet < QString > newPaths ;
for ( const QJsonValue & value : m_manifest . value ( "files" ). toArray ()) {
const QString path = QDir :: fromNativeSeparators ( value . toObject (). value ( "path" ). toString ());
if ( isSafeRelativePath ( path )) newPaths . insert ( path . toCaseFolded ());
}
2026-07-14 08:39:41 +00:00
QSet < QString > protectedPaths {
QStringLiteral ( "bootstrap" ),
QStringLiteral ( "bootstrap.exe" ),
QStringLiteral ( "launcher" ),
QStringLiteral ( "launcher.exe" ),
QStringLiteral ( "updater" ),
QStringLiteral ( "updater.exe" ),
QStringLiteral ( "client.ini" ),
QStringLiteral ( "config/app_config.json" ),
2026-07-10 02:45:13 +00:00
QStringLiteral ( "config/local_state.json" ),
QStringLiteral ( "config/client_identity.dat" ),
QStringLiteral ( "config/version_policy.dat" )
};
const QString runtimePrefix = ConfigHelper :: instance (). runtimeRelativePath (). toCaseFolded ();
if ( ! runtimePrefix . isEmpty ()) {
2026-07-14 08:39:41 +00:00
const QStringList runtimeProtected {
QStringLiteral ( "bootstrap" ), QStringLiteral ( "bootstrap.exe" ),
QStringLiteral ( "launcher" ), QStringLiteral ( "launcher.exe" ),
QStringLiteral ( "updater" ), QStringLiteral ( "updater.exe" ),
QStringLiteral ( "client.ini" ), QStringLiteral ( "config/app_config.json" ),
QStringLiteral ( "config/local_state.json" ), QStringLiteral ( "config/client_identity.dat" ),
QStringLiteral ( "config/version_policy.dat" )
2026-07-10 02:45:13 +00:00
};
for ( const QString & path : runtimeProtected )
protectedPaths . insert ( runtimePrefix + "/" + path );
}
QStringList obsolete ;
QSet < QString > seen ;
for ( const QJsonValue & value : oldManifest . value ( "files" ). toArray ()) {
const QString path = QDir :: fromNativeSeparators ( value . toObject (). value ( "path" ). toString ());
const QString folded = path . toCaseFolded ();
if ( ! isSafeRelativePath ( path ) || protectedPaths . contains ( folded )
|| newPaths . contains ( folded ) || seen . contains ( folded ))
continue ;
seen . insert ( folded );
obsolete . append ( path );
}
obsolete . sort ( Qt :: CaseInsensitive );
return obsolete ;
}
bool UpdaterLogic :: validateLocalFiles ( const QString & stagingDir , const QString & installedDir ) const
{
if ( m_manifest . isEmpty ())
return false ;
const QJsonArray files = m_manifest . value ( "files" ). toArray ();
for ( const auto & item : files )
{
const QJsonObject fileObject = item . toObject ();
const QString path = QDir :: fromNativeSeparators ( fileObject . value ( "path" ). toString ());
const QString expectedSha = fileObject . value ( "sha256" ). toString ();
if ( ! isSafeRelativePath ( path ))
return false ;
if ( isRuntimeProtectedPath ( path )) {
qDebug () << "Runtime-protected manifest entry ignored:" << path ;
continue ;
}
QString fullPath = QDir ( stagingDir ). filePath ( path );
if ( ! QFile :: exists ( fullPath ) && ! installedDir . isEmpty ())
fullPath = QDir ( installedDir ). filePath ( path );
if ( ! QFile :: exists ( fullPath ))
{
qDebug () << "Manifest file missing from staging and installation:" << path ;
return false ;
}
const QString actualSha = calcLocalFileSha256 ( fullPath );
if ( actualSha . compare ( expectedSha , Qt :: CaseInsensitive ) != 0 )
{
qDebug () << "File hash mismatch:" << path << actualSha << expectedSha ;
return false ;
}
}
qDebug () << "Full manifest validation passed using staging plus installed files" ;
return true ;
}
bool UpdaterLogic :: loadOfflinePackage ( const QString & packagePath , const QString & stagingDir )
{
m_offlineError . clear ();
QFile package ( packagePath );
2026-07-14 01:37:06 +00:00
if ( ! package . open ( QIODevice :: ReadOnly )) { m_offlineError = QCoreApplication :: translate ( "UpdaterLogic" , "Cannot open the offline update package" ); return false ; }
if ( package . read ( 8 ) != QByteArray ( "MUPD0001" , 8 )) { m_offlineError = QCoreApplication :: translate ( "UpdaterLogic" , "The offline package format identifier is invalid" ); return false ; }
const QByteArray lengthBytes = package . read ( 8 );
if ( lengthBytes . size () != 8 ) { m_offlineError = QCoreApplication :: translate ( "UpdaterLogic" , "The offline package header is incomplete" ); return false ; }
quint64 headerSize = 0 ;
for ( int i = 0 ; i < 8 ; ++ i ) headerSize |= quint64 ( static_cast < unsigned char > ( lengthBytes [ i ])) << ( i * 8 );
if ( headerSize == 0 || headerSize > 64ULL * 1024 * 1024 || headerSize > quint64 ( package . size () - 16 )) { m_offlineError = QCoreApplication :: translate ( "UpdaterLogic" , "The offline package header length is invalid" ); return false ; }
QJsonParseError error ;
const QJsonDocument wrapperDoc = QJsonDocument :: fromJson ( package . read ( qint64 ( headerSize )), & error );
if ( error . error != QJsonParseError :: NoError || ! wrapperDoc . isObject ()) { m_offlineError = QCoreApplication :: translate ( "UpdaterLogic" , "The offline package header JSON is invalid" ); return false ; }
2026-07-10 02:45:13 +00:00
const QJsonObject wrapper = wrapperDoc . object ();
const QByteArray packageText = wrapper . value ( "package_text" ). toString (). toUtf8 ();
const QByteArray manifestText = wrapper . value ( "manifest_text" ). toString (). toUtf8 ();
const QString publicKey = QApplication :: applicationDirPath () + "/config/manifest_public_key.pem" ;
2026-07-14 01:37:06 +00:00
if ( ! verifySignature ( packageText , wrapper . value ( "package_signature" ). toString (), publicKey )) { m_offlineError = QCoreApplication :: translate ( "UpdaterLogic" , "The offline package RSA signature is invalid" ); return false ; }
const QJsonObject packageMeta = QJsonDocument :: fromJson ( packageText , & error ). object ();
if ( error . error != QJsonParseError :: NoError || packageMeta . value ( "format" ). toString () != "MUPD0001" ) { m_offlineError = QCoreApplication :: translate ( "UpdaterLogic" , "The offline package signature metadata is invalid" ); return false ; }
if ( QString :: fromLatin1 ( QCryptographicHash :: hash ( manifestText , QCryptographicHash :: Sha256 ). toHex ()) != packageMeta . value ( "manifest_sha256" ). toString ()) { m_offlineError = QCoreApplication :: translate ( "UpdaterLogic" , "The manifest digest does not match the package signature" ); return false ; }
QJsonObject manifest = QJsonDocument :: fromJson ( manifestText , & error ). object ();
if ( error . error != QJsonParseError :: NoError || manifest . isEmpty ()) { m_offlineError = QCoreApplication :: translate ( "UpdaterLogic" , "The offline manifest is invalid" ); return false ; }
2026-07-10 02:45:13 +00:00
manifest . insert ( "signature" , wrapper . value ( "manifest_signature" ). toString ());
m_manifest = manifest ; m_manifestText = QString :: fromUtf8 ( manifestText ); m_fileItems . clear ();
2026-07-14 01:37:06 +00:00
if ( manifest . value ( "app_id" ) != packageMeta . value ( "app_id" ) || manifest . value ( "channel" ) != packageMeta . value ( "channel" ) || manifest . value ( "version" ) != packageMeta . value ( "version" )) { m_offlineError = QCoreApplication :: translate ( "UpdaterLogic" , "Package information does not match manifest identity" ); return false ; }
if ( ! verifyManifestSignature ()) { m_offlineError = QCoreApplication :: translate ( "UpdaterLogic" , "The offline manifest RSA signature is invalid" ); return false ; }
2026-07-10 02:45:13 +00:00
const qint64 payloadStart = 16 + qint64 ( headerSize );
const QJsonArray entries = packageMeta . value ( "files" ). toArray ();
for ( const QJsonValue & value : entries ) {
const QJsonObject item = value . toObject (); const QString path = QDir :: fromNativeSeparators ( item . value ( "path" ). toString ());
const qint64 offset = item . value ( "offset" ). toVariant (). toLongLong (); const qint64 size = item . value ( "size" ). toVariant (). toLongLong ();
2026-07-14 01:37:06 +00:00
if ( ! isSafeRelativePath ( path ) || offset < 0 || size < 0 || payloadStart + offset + size > package . size ()) { m_offlineError = QCoreApplication :: translate ( "UpdaterLogic" , "The offline package contains an unsafe path or out-of-range data: %1" ). arg ( path ); return false ; }
FileDownloadItem fi { path , QString (), item . value ( "sha256" ). toString (), size }; m_fileItems . append ( fi );
if ( stagingDir . isEmpty ()) continue ;
const QString target = QDir ( stagingDir ). filePath ( path ); if ( ! QDir (). mkpath ( QFileInfo ( target ). path ()) || ! package . seek ( payloadStart + offset )) { m_offlineError = QCoreApplication :: translate ( "UpdaterLogic" , "Cannot prepare offline file: %1" ). arg ( path ); return false ; }
QSaveFile output ( target ); if ( ! output . open ( QIODevice :: WriteOnly )) { m_offlineError = QCoreApplication :: translate ( "UpdaterLogic" , "Cannot create staged file: %1" ). arg ( path ); return false ; }
QCryptographicHash hash ( QCryptographicHash :: Sha256 ); qint64 remaining = size ;
while ( remaining > 0 ) { const QByteArray block = package . read ( qMin < qint64 > ( remaining , 1024 * 1024 )); if ( block . isEmpty () || output . write ( block ) != block . size ()) { output . cancelWriting (); m_offlineError = QCoreApplication :: translate ( "UpdaterLogic" , "Failed to read offline file: %1" ). arg ( path ); return false ; } hash . addData ( block ); remaining -= block . size (); }
if ( QString :: fromLatin1 ( hash . result (). toHex ()). compare ( fi . sha256 , Qt :: CaseInsensitive ) != 0 || ! output . commit ()) { output . cancelWriting (); m_offlineError = QCoreApplication :: translate ( "UpdaterLogic" , "Offline file hash verification or write failed: %1" ). arg ( path ); return false ; }
}
if ( entries . size () != m_manifest . value ( "files" ). toArray (). size ()) { m_offlineError = QCoreApplication :: translate ( "UpdaterLogic" , "The offline package file count does not match the manifest" ); return false ; }
2026-07-10 02:45:13 +00:00
return true ;
}
void UpdaterLogic :: getDownloadUrl ( const QString & appId , const QString & channel , const QString & targetVer , int versionId )
{
QString url = m_serverAddr + "/api/v1/update/download-url" ;
QJsonObject body ;
body [ "app_id" ] = appId ;
body [ "channel" ] = channel ;
body [ "version" ] = targetVer ;
body [ "version_id" ] = versionId ;
QJsonArray emptyFiles ;
body [ "files" ] = emptyFiles ;
m_http . postRequest ( url , body , [ this ]( int code , const QJsonObject & resp )
{
qDebug () << "Download URL API returned code:" << code ;
m_fileItems . clear ();
if ( code == 200 )
{
QJsonArray fileArr = resp [ "files" ]. toArray ();
for ( auto item : fileArr )
{
QJsonObject obj = item . toObject ();
FileDownloadItem fi ;
fi . path = obj [ "path" ]. toString ();
fi . url = obj [ "url" ]. toString ();
fi . sha256 = obj [ "sha256" ]. toString ();
fi . size = obj [ "size" ]. toVariant (). toLongLong ();
m_fileItems . append ( fi );
qDebug () << "File info:" << fi . path << fi . url << fi . sha256 ;
}
}
else
{
qDebug () << "Failed to get download URL" ;
}
emit fetchUrlFinished ();
});
}
QString UpdaterLogic :: calcLocalFileSha256 ( const QString & filePath ) const
{
QFile f ( filePath );
if ( ! f . open ( QIODevice :: ReadOnly ))
return "" ;
QCryptographicHash hash ( QCryptographicHash :: Sha256 );
while ( ! f . atEnd ())
{
QByteArray block = f . read ( 4096 );
hash . addData ( block );
}
f . close ();
return hash . result (). toHex ();
}
bool UpdaterLogic :: downloadSingleFile ( const QString & url , const QString & savePath ,
const QString & expectSha256 , qint64 expectedSize ,
const QString & resumePartPath )
{
const QString partPath = resumePartPath . isEmpty () ? savePath + ".part" : resumePartPath ;
if ( ! QDir (). mkpath ( QFileInfo ( partPath ). path ())) return false ;
if ( QFile :: exists ( savePath )
&& calcLocalFileSha256 ( savePath ). compare ( expectSha256 , Qt :: CaseInsensitive ) == 0 )
return true ;
QFile :: remove ( savePath );
for ( int attempt = 0 ; attempt < 4 ; ++ attempt )
{
qint64 existingSize = QFileInfo ( partPath ). size ();
if ( expectedSize >= 0 && existingSize > expectedSize )
{
QFile :: remove ( partPath );
existingSize = 0 ;
}
if ( expectedSize >= 0 && existingSize == expectedSize && existingSize > 0 )
{
if ( calcLocalFileSha256 ( partPath ). compare ( expectSha256 , Qt :: CaseInsensitive ) == 0 )
{
QFile :: remove ( savePath );
return QFile :: rename ( partPath , savePath );
}
QFile :: remove ( partPath );
existingSize = 0 ;
}
QFile partFile ( partPath );
const QIODevice :: OpenMode mode = QIODevice :: WriteOnly
| ( existingSize > 0 ? QIODevice :: Append : QIODevice :: Truncate );
if ( ! partFile . open ( mode ))
{
qDebug () << "Cannot open partial download:" << partPath ;
return false ;
}
QNetworkAccessManager manager ;
manager . setProxy ( QNetworkProxy :: NoProxy );
QNetworkRequest request ( url );
request . setTransferTimeout ( 60000 );
if ( existingSize > 0 )
request . setRawHeader ( "Range" , QByteArray ( "bytes=" ) + QByteArray :: number ( existingSize ) + "-" );
QNetworkReply * reply = manager . get ( request );
QEventLoop loop ;
bool writeOk = true ;
QObject :: connect ( reply , & QNetworkReply :: readyRead , [ & ]() {
const QByteArray data = reply -> readAll ();
if ( ! data . isEmpty ()) {
if ( partFile . write ( data ) != data . size ()) writeOk = false ;
m_sessionDownloadedBytes += data . size ();
const qint64 received = qMin ( m_downloadTotalBytes ,
m_downloadCompletedBytes + partFile . size ());
const double speed = m_downloadTimer . elapsed () > 0
? ( m_sessionDownloadedBytes * 1000.0 / m_downloadTimer . elapsed ()) : 0.0 ;
emit downloadProgress ( received , m_downloadTotalBytes , m_currentDownloadPath , speed );
}
});
QObject :: connect ( reply , & QNetworkReply :: finished , & loop , & QEventLoop :: quit );
loop . exec ();
const QByteArray remaining = reply -> readAll ();
if ( ! remaining . isEmpty () && partFile . write ( remaining ) != remaining . size ()) writeOk = false ;
partFile . flush ();
partFile . close ();
const int httpStatus = reply -> attribute ( QNetworkRequest :: HttpStatusCodeAttribute ). toInt ();
const bool networkOk = reply -> error () == QNetworkReply :: NoError ;
const QString networkError = reply -> errorString ();
reply -> deleteLater ();
if ( existingSize > 0 && httpStatus == 200 )
{
2026-07-14 01:37:06 +00:00
// The server ignored Range; the current file is "old fragment + full response" and must be redownloaded safely.
2026-07-10 02:45:13 +00:00
qDebug () << "Server ignored Range; restart full download:" << savePath ;
QFile :: remove ( partPath );
}
else if ( networkOk && writeOk && ( httpStatus == 200 || httpStatus == 206 ))
{
const qint64 actualSize = QFileInfo ( partPath ). size ();
if (( expectedSize < 0 || actualSize == expectedSize )
&& calcLocalFileSha256 ( partPath ). compare ( expectSha256 , Qt :: CaseInsensitive ) == 0 )
{
QFile :: remove ( savePath );
if ( QFile :: rename ( partPath , savePath ))
{
qDebug () << "Download completed/resumed & sha pass:" << savePath ;
return true ;
}
}
else if ( expectedSize >= 0 && actualSize >= expectedSize )
{
qDebug () << "Completed partial file has invalid size or SHA; restart:" << savePath ;
QFile :: remove ( partPath );
}
}
else
{
qDebug () << "Download attempt failed; partial file retained:" << attempt + 1
<< savePath << httpStatus << networkError << "bytes" << QFileInfo ( partPath ). size ();
}
if ( attempt < 3 )
{
const unsigned long delayMs = static_cast < unsigned long > ( 500 * ( 1 << attempt ));
QElapsedTimer delay ;
delay . start ();
while ( delay . elapsed () < static_cast < qint64 > ( delayMs ))
{
QApplication :: processEvents ();
QThread :: msleep ( 50 );
}
}
}
return false ;
}
bool UpdaterLogic :: isRuntimeProtectedPath ( const QString & path ) const
{
const QString normalized = QDir :: fromNativeSeparators ( path ). toCaseFolded ();
2026-07-14 08:39:41 +00:00
QSet < QString > protectedPaths {
QStringLiteral ( "bootstrap" ),
QStringLiteral ( "bootstrap.exe" ),
QStringLiteral ( "client.ini" ),
QStringLiteral ( "config/app_config.json" ),
2026-07-10 02:45:13 +00:00
QStringLiteral ( "config/local_state.json" ),
QStringLiteral ( "config/client_identity.dat" ),
QStringLiteral ( "config/version_policy.dat" )
};
const QString runtimePrefix = ConfigHelper :: instance (). runtimeRelativePath (). toCaseFolded ();
if ( ! runtimePrefix . isEmpty ()) {
2026-07-14 08:39:41 +00:00
const QStringList runtimeProtected {
QStringLiteral ( "bootstrap" ), QStringLiteral ( "bootstrap.exe" ), QStringLiteral ( "client.ini" ),
QStringLiteral ( "config/app_config.json" ), QStringLiteral ( "config/local_state.json" ),
QStringLiteral ( "config/client_identity.dat" ), QStringLiteral ( "config/version_policy.dat" )
};
2026-07-10 02:45:13 +00:00
for ( const QString & protectedPath : runtimeProtected )
protectedPaths . insert ( runtimePrefix + "/" + protectedPath );
}
return protectedPaths . contains ( normalized );
}
bool UpdaterLogic :: isSafeRelativePath ( const QString & path ) const
{
const QString normalized = QDir :: fromNativeSeparators ( path );
const QString clean = QDir :: cleanPath ( normalized );
return ! clean . isEmpty ()
&& ! QDir :: isAbsolutePath ( clean )
&& clean != ".."
&& ! clean . startsWith ( "../" )
&& ! clean . contains ( ":" );
}
qint64 UpdaterLogic :: estimateAdditionalDiskBytes ( const QString & targetDir ,
const QStringList & obsoletePaths ) const
{
qint64 required = 0 ;
const QString cacheDir = QDir ( ConfigHelper :: instance (). updateRoot ()). filePath ( "download_cache" );
for ( const auto & item : m_fileItems ) {
const QString relativePath = QDir :: fromNativeSeparators ( item . path );
if ( isRuntimeProtectedPath ( relativePath )) continue ;
const QString installed = QDir ( targetDir ). filePath ( relativePath );
if ( QFile :: exists ( installed )
&& calcLocalFileSha256 ( installed ). compare ( item . sha256 , Qt :: CaseInsensitive ) == 0 )
continue ;
const qint64 partialSize = QFileInfo ( QDir ( cacheDir ). filePath (
item . sha256 . toLower () + ".part" )). size ();
required += qMax < qint64 > ( 0 , item . size - qMin ( item . size , partialSize ));
if ( QFile :: exists ( installed )) required += QFileInfo ( installed ). size ();
}
for ( const QString & path : obsoletePaths ) {
const QString installed = QDir ( targetDir ). filePath ( path );
if ( QFile :: exists ( installed )) required += QFileInfo ( installed ). size ();
}
const qint64 reserve = qMax < qint64 > ( 128LL * 1024 * 1024 , required / 10 );
return required + reserve ;
}
bool UpdaterLogic :: downloadAllFiles ( const QString & tempDir , const QString & targetDir )
{
if ( m_fileItems . isEmpty ())
{
m_downloadAllOk = false ;
return false ;
}
QDir stagingDir ( tempDir );
if ( ! FileHelper :: createDir ( tempDir ))
return false ;
const QString resumeCacheDir = QDir ( ConfigHelper :: instance (). updateRoot ()). filePath ( "download_cache" );
if ( ! FileHelper :: createDir ( resumeCacheDir ))
return false ;
QSet < QString > activePartialNames ;
for ( const auto & item : m_fileItems )
activePartialNames . insert ( item . sha256 . toLower () + ".part" );
QDir cacheDir ( resumeCacheDir );
for ( const QString & fileName : cacheDir . entryList ( QStringList () << "*.part" , QDir :: Files )) {
if ( ! activePartialNames . contains ( fileName . toLower ()))
cacheDir . remove ( fileName );
}
m_downloadTotalBytes = 0 ;
m_downloadCompletedBytes = 0 ;
m_sessionDownloadedBytes = 0 ;
for ( const auto & item : m_fileItems ) {
const QString itemPath = QDir :: fromNativeSeparators ( item . path );
if ( isRuntimeProtectedPath ( itemPath )) continue ;
const QString installed = QDir ( targetDir ). filePath ( itemPath );
if ( ! QFile :: exists ( installed )
|| calcLocalFileSha256 ( installed ). compare ( item . sha256 , Qt :: CaseInsensitive ) != 0 )
m_downloadTotalBytes += item . size ;
}
m_downloadTimer . restart ();
emit downloadProgress ( 0 , m_downloadTotalBytes , QString (), 0.0 );
for ( const auto & fi : m_fileItems )
{
if ( ! isSafeRelativePath ( fi . path ))
{
qDebug () << "Unsafe relative path in manifest:" << fi . path ;
m_downloadAllOk = false ;
return false ;
}
const QString relativePath = QDir :: fromNativeSeparators ( fi . path );
if ( isRuntimeProtectedPath ( relativePath )) {
qDebug () << "Runtime-protected file skipped:" << relativePath ;
continue ;
}
const QString installedFile = QDir ( targetDir ). filePath ( relativePath );
if ( QFile :: exists ( installedFile )
&& calcLocalFileSha256 ( installedFile ). compare ( fi . sha256 , Qt :: CaseInsensitive ) == 0 )
{
qDebug () << "Unchanged file, skip download:" << relativePath ;
continue ;
}
const QString tempFile = QDir ( tempDir ). filePath ( relativePath );
const QString parentDir = QFileInfo ( tempFile ). path ();
if ( ! QDir (). mkpath ( parentDir ))
{
qDebug () << "Cannot create staging subdirectory:" << parentDir ;
m_downloadAllOk = false ;
return false ;
}
const QString resumePartPath = QDir ( resumeCacheDir ). filePath ( fi . sha256 . toLower () + ".part" );
m_currentDownloadPath = relativePath ;
const qint64 resumedBytes = qMin ( fi . size , QFileInfo ( resumePartPath ). size ());
const double speed = m_downloadTimer . elapsed () > 0
? ( m_sessionDownloadedBytes * 1000.0 / m_downloadTimer . elapsed ()) : 0.0 ;
emit downloadProgress ( m_downloadCompletedBytes + resumedBytes ,
m_downloadTotalBytes , m_currentDownloadPath , speed );
if ( ! downloadSingleFile ( fi . url , tempFile , fi . sha256 , fi . size , resumePartPath ))
{
m_downloadAllOk = false ;
return false ;
}
m_downloadCompletedBytes += fi . size ;
emit downloadProgress ( m_downloadCompletedBytes , m_downloadTotalBytes ,
m_currentDownloadPath , speed );
}
m_downloadAllOk = true ;
return true ;
}
void UpdaterLogic :: reportDownloadResult ( const QString & appId , const QString & channel ,
const QString & version , bool success )
{
QJsonArray files ;
for ( const FileDownloadItem & item : m_fileItems )
files . append ( QJsonObject {{ "path" , item . path }, { "size" , item . size }});
QJsonObject body {{ "app_id" , appId }, { "channel" , channel }, { "version" , version },
{ "result" , success ? "success" : "fail" }, { "files" , files }};
m_http . postRequest ( m_serverAddr + "/api/v1/update/download-report" , body ,
[]( int code , const QJsonObject & ) { qDebug () << "Download result report returned code:" << code ; });
}
void UpdaterLogic :: reportResult ( const QString & deviceId ,
const QString & fromVer ,
const QString & toVer ,
bool success )
{
QString url = m_serverAddr + "/api/v1/update/report" ;
QJsonObject body ;
body [ "app_id" ] = ConfigHelper :: instance (). getValue ( "App" , "app_id" );
body [ "device_id" ] = deviceId ;
body [ "from_version" ] = fromVer ;
body [ "to_version" ] = toVer ;
if ( success )
body [ "result" ] = "success" ;
else
body [ "result" ] = "fail" ;
m_http . postRequest ( url , body , []( int code , const QJsonObject & resp )
{
Q_UNUSED ( resp );
qDebug () << "Update result report returned code:" << code ;
});
}
QList < FileDownloadItem > UpdaterLogic :: getFileList () const
{
return m_fileItems ;
}