2026-07-09 09:17:30 +00:00
#include "UpdaterLogic.h"
#include <QSet>
#include <QDebug>
#include <QFileInfo>
#include <QFile>
#include <QEventLoop>
#include <QElapsedTimer>
#include <QThread>
#include <QNetworkRequest>
#include <QNetworkProxy>
#include <QJsonArray>
#include <QCryptographicHash>
#include <QDir>
#include <QJsonDocument>
#include <QSaveFile>
#include <QApplication>
2026-07-10 00:38:23 -07:00
#include <QCoreApplication>
#include <QUrl>
2026-07-09 09:17:30 +00:00
#include <algorithm>
2026-07-10 00:38:23 -07:00
#include <cmath>
2026-07-09 09:17:30 +00:00
#include "ConfigHelper.h"
2026-07-12 17:36:53 -07:00
#include "RuntimeProtectionPolicy.h"
2026-07-09 09:17:30 +00:00
#ifdef HAVE_OPENSSL
#include <openssl/pem.h>
#include <openssl/evp.h>
#include <openssl/bio.h>
#include <openssl/err.h>
#endif
2026-07-10 00:38:23 -07:00
namespace
{
bool jsonNonNegativeInteger ( const QJsonValue & value , qint64 * result )
{
constexpr double maxExactJsonInteger = 9007199254740991.0 ;
if ( ! value . isDouble ())
return false ;
const double number = value . toDouble ();
if ( ! std :: isfinite ( number ) || number < 0.0 || number > maxExactJsonInteger
|| std :: floor ( number ) != number )
return false ;
if ( result )
* result = static_cast < qint64 > ( number );
return true ;
}
bool isSha256 ( const QString & value )
{
if ( value . size () != 64 )
return false ;
for ( const QChar ch : value )
{
const ushort code = ch . unicode ();
if ( ! (( code >= '0' && code <= '9' )
|| ( code >= 'a' && code <= 'f' )
|| ( code >= 'A' && code <= 'F' )))
return false ;
}
return true ;
}
}
2026-07-09 09:17:30 +00:00
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 )
{
2026-07-10 00:38:23 -07:00
m_manifest = QJsonObject ();
m_manifestText . clear ();
m_fileItems . clear ();
m_manifestSignatureVerified = false ;
2026-07-09 09:17:30 +00:00
QString url = m_serverAddr + "/api/v1/update/manifest" ;
QJsonObject body ;
body [ "app_id" ] = appId ;
body [ "channel" ] = channel ;
body [ "version" ] = targetVer ;
body [ "version_id" ] = versionId ;
2026-07-10 00:38:23 -07:00
m_http . postRequest ( url , body , [ this , appId , channel , targetVer , versionId ]( int code , const QJsonObject & resp )
2026-07-09 09:17:30 +00:00
{
qDebug () << "Manifest API returned code:" << code ;
m_manifest = QJsonObject ();
m_manifestText . clear ();
2026-07-10 00:38:23 -07:00
m_fileItems . clear ();
m_manifestSignatureVerified = false ;
2026-07-09 09:17:30 +00:00
if ( code == 200 )
{
2026-07-10 00:38:23 -07:00
const QJsonValue manifestTextValue = resp . value ( "manifest_text" );
const QJsonValue responseManifestValue = resp . value ( "manifest" );
if ( ! manifestTextValue . isString () || ! responseManifestValue . isObject ())
2026-07-09 09:17:30 +00:00
{
2026-07-10 00:38:23 -07:00
qDebug () << "Manifest response fields have invalid types" ;
2026-07-09 09:17:30 +00:00
}
else
{
2026-07-10 00:38:23 -07:00
const QString manifestText = manifestTextValue . toString ();
const QJsonObject responseManifest = responseManifestValue . toObject ();
const QJsonValue signatureValue = responseManifest . value ( "signature" );
QJsonParseError parseError ;
const QJsonDocument manifestDocument =
QJsonDocument :: fromJson ( manifestText . toUtf8 (), & parseError );
if ( manifestText . isEmpty () || ! signatureValue . isString ()
|| signatureValue . toString (). trimmed (). isEmpty ()
|| parseError . error != QJsonParseError :: NoError
|| ! manifestDocument . isObject ())
{
qDebug () << "Manifest response JSON or signature field is invalid" ;
}
else
{
QJsonObject trustedManifest = manifestDocument . object ();
QList < FileDownloadItem > trustedFiles ;
if ( trustedManifest . contains ( "signature" ))
{
qDebug () << "Signed manifest text unexpectedly contains a signature field" ;
}
else
{
trustedManifest . insert ( "signature" , signatureValue . toString ());
if ( validateOnlineManifest ( trustedManifest , appId , channel ,
targetVer , versionId , & trustedFiles ))
{
m_manifestText = manifestText ;
m_manifest = trustedManifest ;
m_fileItems = trustedFiles ;
qDebug () << "Received trusted manifest version:"
<< m_manifest . value ( "version" ). toString ();
}
else
{
qDebug () << "Manifest fields or request identity are invalid" ;
}
}
}
2026-07-09 09:17:30 +00:00
}
}
else
{
qDebug () << "Failed to get manifest" ;
}
emit fetchUrlFinished ();
});
}
2026-07-10 00:38:23 -07:00
bool UpdaterLogic :: validateOnlineManifest ( const QJsonObject & manifest , const QString & appId ,
const QString & channel , const QString & targetVer ,
int versionId , QList < FileDownloadItem >* fileItems ) const
{
if ( ! fileItems )
return false ;
fileItems -> clear ();
const QJsonValue appValue = manifest . value ( "app_id" );
const QJsonValue channelValue = manifest . value ( "channel" );
const QJsonValue versionValue = manifest . value ( "version" );
const QJsonValue sequenceValue = manifest . value ( "manifest_seq" );
const QJsonValue filesValue = manifest . value ( "files" );
const QJsonValue signatureValue = manifest . value ( "signature" );
qint64 manifestSequence = - 1 ;
if ( ! appValue . isString () || appValue . toString (). isEmpty ()
|| ! channelValue . isString () || channelValue . toString (). isEmpty ()
|| ! versionValue . isString () || versionValue . toString (). isEmpty ()
|| ! jsonNonNegativeInteger ( sequenceValue , & manifestSequence )
|| ! filesValue . isArray ()
|| ! signatureValue . isString () || signatureValue . toString (). trimmed (). isEmpty ()
|| appValue . toString () != appId
|| channelValue . toString () != channel
|| versionValue . toString () != targetVer
|| manifestSequence != static_cast < qint64 > ( versionId ))
return false ;
QSet < QString > pathKeys ;
const QJsonArray files = filesValue . toArray ();
fileItems -> reserve ( files . size ());
for ( const QJsonValue & value : files )
{
if ( ! value . isObject ())
{
fileItems -> clear ();
return false ;
}
const QJsonObject file = value . toObject ();
const QJsonValue pathValue = file . value ( "path" );
const QJsonValue shaValue = file . value ( "sha256" );
const QJsonValue sizeValue = file . value ( "size" );
qint64 size = - 1 ;
if ( ! pathValue . isString () || pathValue . toString (). isEmpty ()
|| ! shaValue . isString () || ! isSha256 ( shaValue . toString ())
|| ! jsonNonNegativeInteger ( sizeValue , & size ))
{
fileItems -> clear ();
return false ;
}
const QString path = pathValue . toString ();
const QString normalizedPath = QDir :: cleanPath ( QDir :: fromNativeSeparators ( path ));
const QString pathKey = normalizedPath . toCaseFolded ();
if ( ! isSafeRelativePath ( path ) || pathKeys . contains ( pathKey ))
{
fileItems -> clear ();
return false ;
}
pathKeys . insert ( pathKey );
FileDownloadItem item ;
item . path = path ;
item . sha256 = shaValue . toString ();
item . size = size ;
fileItems -> append ( item );
}
return true ;
}
2026-07-09 09:17:30 +00:00
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
{
2026-07-10 00:38:23 -07:00
m_manifestSignatureVerified = false ;
2026-07-09 09:17:30 +00:00
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 ;
}
2026-07-10 00:38:23 -07:00
m_manifestSignatureVerified = verifySignature ( m_manifestText . toUtf8 (), signature , publicKeyPath );
return m_manifestSignatureVerified ;
2026-07-09 09:17:30 +00:00
}
bool UpdaterLogic :: saveManifestCache ( const QString & cacheDir ) const
{
2026-07-10 00:38:23 -07:00
if ( m_manifest . isEmpty () || ! m_manifestSignatureVerified )
2026-07-09 09:17:30 +00:00
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 )
{
2026-07-10 00:38:23 -07:00
m_manifest = QJsonObject ();
m_manifestText . clear ();
m_fileItems . clear ();
m_manifestSignatureVerified = false ;
2026-07-09 09:17:30 +00:00
QString filePath = cacheDir + "/manifest_" + version + ".json" ;
QFile file ( filePath );
if ( ! file . exists () || ! file . open ( QIODevice :: ReadOnly ))
return false ;
QByteArray raw = file . readAll ();
file . close ();
2026-07-10 00:38:23 -07:00
QJsonParseError wrapperError ;
const QJsonDocument doc = QJsonDocument :: fromJson ( raw , & wrapperError );
if ( wrapperError . error != QJsonParseError :: NoError || ! doc . isObject ())
2026-07-09 09:17:30 +00:00
return false ;
2026-07-10 00:38:23 -07:00
const QJsonObject wrapper = doc . object ();
const QJsonValue manifestTextValue = wrapper . value ( "manifest_text" );
const QJsonValue cachedManifestValue = wrapper . value ( "manifest" );
if ( ! manifestTextValue . isString () || manifestTextValue . toString (). isEmpty ()
|| ! cachedManifestValue . isObject ())
2026-07-09 09:17:30 +00:00
return false ;
2026-07-10 00:38:23 -07:00
const QJsonValue signatureValue = cachedManifestValue . toObject (). value ( "signature" );
QJsonParseError manifestError ;
const QString manifestText = manifestTextValue . toString ();
const QJsonDocument manifestDocument =
QJsonDocument :: fromJson ( manifestText . toUtf8 (), & manifestError );
if ( ! signatureValue . isString () || signatureValue . toString (). trimmed (). isEmpty ()
|| manifestError . error != QJsonParseError :: NoError
|| ! manifestDocument . isObject ())
return false ;
QJsonObject trustedManifest = manifestDocument . object ();
const QJsonValue versionValue = trustedManifest . value ( "version" );
if ( trustedManifest . contains ( "signature" ) || ! versionValue . isString ()
|| versionValue . toString () != version )
return false ;
trustedManifest . insert ( "signature" , signatureValue . toString ());
m_manifest = trustedManifest ;
m_manifestText = manifestText ;
2026-07-09 09:17:30 +00:00
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 ());
}
QSet < QString > protectedPaths {
QStringLiteral ( "launcher.exe" ),
2026-07-12 18:44:11 -07:00
QStringLiteral ( "updater.exe" )
2026-07-09 09:17:30 +00:00
};
const QString runtimePrefix = ConfigHelper :: instance (). runtimeRelativePath (). toCaseFolded ();
if ( ! runtimePrefix . isEmpty ()) {
const QStringList runtimeProtected {
2026-07-12 18:44:11 -07:00
QStringLiteral ( "launcher.exe" ), QStringLiteral ( "updater.exe" )
2026-07-09 09:17:30 +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 )
2026-07-12 18:44:11 -07:00
|| isRuntimeProtectedPath ( path )
2026-07-09 09:17:30 +00:00
|| 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-10 00:38:23 -07: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 ; }
2026-07-09 09:17:30 +00:00
const QByteArray lengthBytes = package . read ( 8 );
2026-07-10 00:38:23 -07:00
if ( lengthBytes . size () != 8 ) { m_offlineError = QCoreApplication :: translate ( "UpdaterLogic" , "The offline package header is incomplete." ); return false ; }
2026-07-09 09:17:30 +00:00
quint64 headerSize = 0 ;
for ( int i = 0 ; i < 8 ; ++ i ) headerSize |= quint64 ( static_cast < unsigned char > ( lengthBytes [ i ])) << ( i * 8 );
2026-07-10 00:38:23 -07:00
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 ; }
2026-07-09 09:17:30 +00:00
QJsonParseError error ;
const QJsonDocument wrapperDoc = QJsonDocument :: fromJson ( package . read ( qint64 ( headerSize )), & error );
2026-07-10 00:38:23 -07:00
if ( error . error != QJsonParseError :: NoError || ! wrapperDoc . isObject ()) { m_offlineError = QCoreApplication :: translate ( "UpdaterLogic" , "The offline package header contains invalid JSON." ); return false ; }
2026-07-09 09:17:30 +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 ();
2026-07-10 00:38:23 -07:00
const QString publicKey = QStringLiteral ( ":/simcae/update-client/manifest-public-key.pem" );
if ( ! verifySignature ( packageText , wrapper . value ( "package_signature" ). toString (), publicKey )) { m_offlineError = QCoreApplication :: translate ( "UpdaterLogic" , "The offline package RSA signature is invalid." ); return false ; }
2026-07-09 09:17:30 +00:00
const QJsonObject packageMeta = QJsonDocument :: fromJson ( packageText , & error ). object ();
2026-07-10 00:38:23 -07:00
if ( error . error != QJsonParseError :: NoError || packageMeta . value ( "format" ). toString () != "MUPD0001" ) { m_offlineError = QCoreApplication :: translate ( "UpdaterLogic" , "The signed offline package 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 ; }
2026-07-09 09:17:30 +00:00
QJsonObject manifest = QJsonDocument :: fromJson ( manifestText , & error ). object ();
2026-07-10 00:38:23 -07:00
if ( error . error != QJsonParseError :: NoError || manifest . isEmpty ()) { m_offlineError = QCoreApplication :: translate ( "UpdaterLogic" , "The offline manifest is invalid." ); return false ; }
2026-07-09 09:17:30 +00:00
manifest . insert ( "signature" , wrapper . value ( "manifest_signature" ). toString ());
m_manifest = manifest ; m_manifestText = QString :: fromUtf8 ( manifestText ); m_fileItems . clear ();
2026-07-10 00:38:23 -07: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" , "The package metadata and manifest identity do not match." ); return false ; }
if ( ! verifyManifestSignature ()) { m_offlineError = QCoreApplication :: translate ( "UpdaterLogic" , "The offline manifest RSA signature is invalid." ); return false ; }
2026-07-09 09:17:30 +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-10 00:38:23 -07: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-bounds data: %1" ). arg ( path ); return false ; }
2026-07-09 09:17:30 +00:00
FileDownloadItem fi { path , QString (), item . value ( "sha256" ). toString (), size }; m_fileItems . append ( fi );
if ( stagingDir . isEmpty ()) continue ;
2026-07-10 00:38:23 -07:00
const QString target = QDir ( stagingDir ). filePath ( path ); if ( ! QDir (). mkpath ( QFileInfo ( target ). path ()) || ! package . seek ( payloadStart + offset )) { m_offlineError = QCoreApplication :: translate ( "UpdaterLogic" , "Cannot prepare the offline file: %1" ). arg ( path ); return false ; }
QSaveFile output ( target ); if ( ! output . open ( QIODevice :: WriteOnly )) { m_offlineError = QCoreApplication :: translate ( "UpdaterLogic" , "Cannot create the staged file: %1" ). arg ( path ); return false ; }
2026-07-09 09:17:30 +00:00
QCryptographicHash hash ( QCryptographicHash :: Sha256 ); qint64 remaining = size ;
2026-07-10 00:38:23 -07:00
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 the 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 validation or writing failed: %1" ). arg ( path ); return false ; }
2026-07-09 09:17:30 +00:00
}
2026-07-10 00:38:23 -07:00
if ( entries . size () != m_manifest . value ( "files" ). toArray (). size ()) { m_offlineError = QCoreApplication :: translate ( "UpdaterLogic" , "The number of files in the offline package does not match the manifest." ); return false ; }
2026-07-09 09:17:30 +00:00
return true ;
}
void UpdaterLogic :: getDownloadUrl ( const QString & appId , const QString & channel , const QString & targetVer , int versionId )
{
2026-07-10 00:38:23 -07:00
QList < FileDownloadItem > signedFiles ;
m_fileItems . clear ();
if ( ! m_manifestSignatureVerified
|| ! validateOnlineManifest ( m_manifest , appId , channel , targetVer , versionId , & signedFiles ))
{
qDebug () << "Download URL request rejected because the signed manifest is not valid for the request" ;
emit fetchUrlFinished ();
return ;
}
if ( signedFiles . isEmpty ())
{
qDebug () << "Download URL request rejected because the signed manifest has no files" ;
emit fetchUrlFinished ();
return ;
}
2026-07-09 09:17:30 +00:00
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 ;
2026-07-10 00:38:23 -07:00
m_http . postRequest ( url , body , [ this , signedFiles ]( int code , const QJsonObject & resp )
2026-07-09 09:17:30 +00:00
{
qDebug () << "Download URL API returned code:" << code ;
m_fileItems . clear ();
if ( code == 200 )
{
2026-07-10 00:38:23 -07:00
const QJsonValue filesValue = resp . value ( "files" );
if ( ! filesValue . isArray ())
{
qDebug () << "Download URL response files field is invalid" ;
emit fetchUrlFinished ();
return ;
}
const QJsonArray responseFiles = filesValue . toArray ();
if ( responseFiles . size () != signedFiles . size ())
2026-07-09 09:17:30 +00:00
{
2026-07-10 00:38:23 -07:00
qDebug () << "Download URL response file count does not match the signed manifest" ;
emit fetchUrlFinished ();
return ;
2026-07-09 09:17:30 +00:00
}
2026-07-10 00:38:23 -07:00
QMap < QString , int > signedIndexes ;
for ( int index = 0 ; index < signedFiles . size (); ++ index )
signedIndexes . insert ( signedFiles . at ( index ). path , index );
QList < FileDownloadItem > boundFiles = signedFiles ;
QSet < QString > responsePaths ;
for ( const QJsonValue & value : responseFiles )
{
if ( ! value . isObject ())
{
qDebug () << "Download URL response contains a non-object file item" ;
emit fetchUrlFinished ();
return ;
}
const QJsonObject responseFile = value . toObject ();
const QJsonValue pathValue = responseFile . value ( "path" );
const QJsonValue urlValue = responseFile . value ( "url" );
const QJsonValue shaValue = responseFile . value ( "sha256" );
const QUrl downloadUrl ( urlValue . toString ());
qint64 size = - 1 ;
if ( ! pathValue . isString () || pathValue . toString (). isEmpty ()
|| ! urlValue . isString () || urlValue . toString (). trimmed (). isEmpty ()
|| ! downloadUrl . isValid () || downloadUrl . host (). isEmpty ()
|| ( downloadUrl . scheme (). compare ( "http" , Qt :: CaseInsensitive ) != 0
&& downloadUrl . scheme (). compare ( "https" , Qt :: CaseInsensitive ) != 0 )
|| ! shaValue . isString () || ! isSha256 ( shaValue . toString ())
|| ! jsonNonNegativeInteger ( responseFile . value ( "size" ), & size ))
{
qDebug () << "Download URL response contains invalid file metadata" ;
emit fetchUrlFinished ();
return ;
}
const QString path = pathValue . toString ();
if ( responsePaths . contains ( path ) || ! signedIndexes . contains ( path ))
{
qDebug () << "Download URL response contains a duplicate or extra file:" << path ;
emit fetchUrlFinished ();
return ;
}
responsePaths . insert ( path );
const int index = signedIndexes . value ( path );
const FileDownloadItem & signedFile = signedFiles . at ( index );
if ( shaValue . toString (). compare ( signedFile . sha256 , Qt :: CaseInsensitive ) != 0
|| size != signedFile . size )
{
qDebug () << "Download URL response metadata differs from the signed manifest:" << path ;
emit fetchUrlFinished ();
return ;
}
boundFiles [ index ]. url = urlValue . toString ();
}
if ( responsePaths . size () != signedFiles . size ())
{
qDebug () << "Download URL response is missing signed manifest files" ;
emit fetchUrlFinished ();
return ;
}
m_fileItems = boundFiles ;
qDebug () << "Bound download URLs to" << m_fileItems . size ()
<< "signed manifest files" ;
2026-07-09 09:17:30 +00:00
}
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-10 00:38:23 -07:00
// The server ignored Range, so restart instead of appending a full response to the partial file.
2026-07-09 09:17:30 +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
{
2026-07-12 17:36:53 -07:00
return UpdateClient :: isRuntimeProtectedPath (
path , ConfigHelper :: instance (). runtimeRelativePath ());
2026-07-09 09:17:30 +00:00
}
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 ;
}