diff --git a/.github/workflows/canary.yml b/.github/workflows/canary.yml index 8955413764..4b102d28d4 100644 --- a/.github/workflows/canary.yml +++ b/.github/workflows/canary.yml @@ -292,6 +292,9 @@ jobs: # windows-11-arm runner and the VS ARM64 build tools steps from # win.yml when the canary channels grow an arm feed. + outputs: + signed: ${{ steps.sign.outputs.signed }} + env: PREPARE_PATH: "Telegram/build/prepare/prepare.py" @@ -525,6 +528,7 @@ jobs: # --endpoint-url https://$R2_ACCOUNT_ID.r2.cloudflarestorage.com - name: Sign binaries. + id: sign shell: bash env: SM_API_KEY: ${{ secrets.SM_API_KEY }} @@ -541,6 +545,7 @@ jobs: exit 1 fi echo "::warning::KeyLocker secrets absent, leaving binaries unsigned." + echo "signed=false" >> $GITHUB_OUTPUT exit 0 fi # TODO(canary-infra): install the DigiCert KeyLocker tools once @@ -554,6 +559,7 @@ jobs: # the publish job can re-check just the portable's Telegram.exe. signtool verify /pa /all Telegram.exe signtool verify /pa /all Updater.exe + echo "signed=true" >> $GITHUB_OUTPUT - name: Azure login for update signing. if: needs.version.outputs.publish == 'true' @@ -637,6 +643,9 @@ jobs: contents: read id-token: write + outputs: + signed: ${{ steps.sign.outputs.signed }} + env: PREPARE_PATH: "Telegram/build/prepare/prepare.py" @@ -790,6 +799,7 @@ jobs: done - name: Sign and notarize. + id: sign env: CERTIFICATE_P12_B64: ${{ secrets.MACOS_CERTIFICATE_P12_B64 }} CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} @@ -807,6 +817,7 @@ jobs: exit 1 fi echo "::warning::No signing certificate, leaving the apps unsigned." + echo "signed=false" >> $GITHUB_OUTPUT exit 0 fi echo "$CERTIFICATE_P12_B64" | base64 -d > /tmp/certificate.p12 @@ -834,6 +845,7 @@ jobs: spctl --assess --type execute --verbose=2 "$BUNDLE" rm notarize.zip done + echo "signed=true" >> $GITHUB_OUTPUT - name: Azure login for update signing. if: needs.version.outputs.publish == 'true' @@ -1226,7 +1238,7 @@ jobs: COMMIT: ${{ needs.version.outputs.commit }} VERSION_STR: ${{ needs.version.outputs.version_str }} PREVIOUS: ${{ needs.version.outputs.previous }} - UNSIGNED: ${{ needs.version.outputs.unsigned }} + SIGNED: ${{ needs.windows.outputs.signed }} KEYS_LOC: Telegram/Resources/update UPDATE_DIR: artifacts/update PORTABLE_DIR: artifacts/portable @@ -1315,7 +1327,7 @@ jobs: COMMIT: ${{ needs.version.outputs.commit }} VERSION_STR: ${{ needs.version.outputs.version_str }} PREVIOUS: ${{ needs.version.outputs.previous }} - UNSIGNED: ${{ needs.version.outputs.unsigned }} + SIGNED: ${{ needs.macos.outputs.signed }} KEYS_LOC: Telegram/Resources/update UPDATE_DIR: artifacts/update PORTABLE_DIR: artifacts/portable @@ -1390,7 +1402,7 @@ jobs: COMMIT: ${{ needs.version.outputs.commit }} VERSION_STR: ${{ needs.version.outputs.version_str }} PREVIOUS: ${{ needs.version.outputs.previous }} - UNSIGNED: ${{ needs.version.outputs.unsigned }} + SIGNED: true KEYS_LOC: Telegram/Resources/update UPDATE_DIR: artifacts/update PORTABLE_DIR: artifacts/portable diff --git a/Telegram/SourceFiles/core/launcher.cpp b/Telegram/SourceFiles/core/launcher.cpp index 2487d4d5bd..ef9f96f1b5 100644 --- a/Telegram/SourceFiles/core/launcher.cpp +++ b/Telegram/SourceFiles/core/launcher.cpp @@ -383,6 +383,12 @@ int Launcher::exec() { return psFixPrevious(); } + // Before Logs::start(), which is where the working directory gets + // chosen: a translocated bundle never sees its TelegramForcePortable. + if (!Platform::CheckAppTranslocation()) { + return 0; + } + // Must be started before Platform is started. Logs::start(); base::options::init(cWorkingDir() + "tdata/experimental_options.json"); @@ -549,6 +555,7 @@ void Launcher::processArguments() { auto parseMap = std::map { { "-debug" , KeyFormat::NoValues }, { "-testagent" , KeyFormat::NoValues }, + { Platform::kUntranslocatedArgument, KeyFormat::NoValues }, { "-key" , KeyFormat::OneValue }, { "-autostart" , KeyFormat::NoValues }, { "-fixprevious" , KeyFormat::NoValues }, diff --git a/Telegram/SourceFiles/platform/linux/specific_linux.h b/Telegram/SourceFiles/platform/linux/specific_linux.h index c3d0cf5d7d..8e1ac22aed 100644 --- a/Telegram/SourceFiles/platform/linux/specific_linux.h +++ b/Telegram/SourceFiles/platform/linux/specific_linux.h @@ -11,6 +11,10 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL namespace Platform { +inline bool CheckAppTranslocation() { + return true; +} + inline void IgnoreApplicationActivationRightNow() { } diff --git a/Telegram/SourceFiles/platform/mac/specific_mac.mm b/Telegram/SourceFiles/platform/mac/specific_mac.mm index f58aecb431..d5643d100a 100644 --- a/Telegram/SourceFiles/platform/mac/specific_mac.mm +++ b/Telegram/SourceFiles/platform/mac/specific_mac.mm @@ -12,6 +12,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "history/history_widget.h" #include "core/crash_reports.h" #include "core/sandbox.h" +#include "core/launcher.h" #include "core/application.h" #include "core/core_settings.h" #include "storage/localstorage.h" @@ -22,11 +23,14 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "base/platform/mac/base_utilities_mac.h" #include "base/platform/base_platform_info.h" +#include #include #include #include +#include #include +#include #include #include @@ -107,6 +111,154 @@ int psFixPrevious() { return 0; } +#ifndef OS_MAC_STORE +namespace { + +struct TranslocationState { + bool translocated = false; + QString original; // The bundle path macOS made the copy from. +}; + +// Security.framework exports these since 10.12 without declaring them in +// the public headers; the path check is the fallback when they are not +// resolvable. +using SecTranslocateIsTranslocatedURLFn = Boolean(*)( + CFURLRef, + bool*, + CFErrorRef*); +using SecTranslocateCreateOriginalPathForURLFn = CFURLRef(*)( + CFURLRef, + CFErrorRef*); + +[[nodiscard]] TranslocationState CheckTranslocationState( + const QString &bundle) { + auto result = TranslocationState(); + NSURL *url = [NSURL fileURLWithPath:Platform::Q2NSString(bundle)]; + const auto security = dlopen( + "/System/Library/Frameworks/Security.framework/Security", + RTLD_LAZY); + const auto isTranslocated = security + ? reinterpret_cast( + dlsym(security, "SecTranslocateIsTranslocatedURL")) + : nullptr; + const auto createOriginal = security + ? reinterpret_cast( + dlsym(security, "SecTranslocateCreateOriginalPathForURL")) + : nullptr; + + auto flag = false; + if (isTranslocated && isTranslocated((CFURLRef)url, &flag, nullptr)) { + result.translocated = flag; + } else { + result.translocated = bundle.contains(u"/AppTranslocation/"_q); + } + if (!result.translocated) { + return result; + } + if (createOriginal) { + if (const auto original = createOriginal((CFURLRef)url, nullptr)) { + result.original = Platform::NS2QString([(NSURL*)original path]); + CFRelease(original); + } + } + if (result.original.isEmpty()) { + // The translocated bundle lives on a read-only mount of the + // original location, so the mount source names it. + struct statfs info; + if (statfs(QFile::encodeName(bundle).constData(), &info) == 0) { + auto from = QFile::decodeName(info.f_mntfromname); + const auto name = QFileInfo(bundle).fileName(); + if (!from.endsWith('/' + name)) { + from += '/' + name; + } + result.original = from; + } + } + return result; +} + +// Every item of the bundle carries the attribute, and a single leftover +// would translocate the relaunch again. +[[nodiscard]] bool RemoveQuarantineRecursively(const QString &bundle) { + constexpr auto kAttribute = "com.apple.quarantine"; + const auto remove = [&](const QString &path) { + const auto local = QFile::encodeName(path); + if (removexattr(local.constData(), kAttribute, XATTR_NOFOLLOW) == 0 + || errno == ENOATTR) { + return true; + } + LOG(("Translocation Error: removexattr failed for '%1': %2" + ).arg(path + ).arg(errno)); + return false; + }; + if (!remove(bundle)) { + return false; + } + auto iterator = QDirIterator( + bundle, + QDir::AllEntries + | QDir::Hidden + | QDir::System + | QDir::NoDotAndDotDot, + QDirIterator::Subdirectories); + while (iterator.hasNext()) { + if (!remove(iterator.next())) { + return false; + } + } + const auto local = QFile::encodeName(bundle); + const auto left = getxattr( + local.constData(), + kAttribute, + nullptr, + 0, + 0, + XATTR_NOFOLLOW); + return (left < 0) && (errno == ENOATTR); +} + +[[nodiscard]] bool RelaunchUntranslocated(const QString &bundle) { + NSDictionary *conf = @{ + NSWorkspaceLaunchConfigurationArguments: @[ + [NSString stringWithUTF8String:Platform::kUntranslocatedArgument] + ], + }; + NSError *error = nil; + const auto launched = [[NSWorkspace sharedWorkspace] + launchApplicationAtURL:[NSURL fileURLWithPath:Platform::Q2NSString(bundle)] + options:NSWorkspaceLaunchAsync | NSWorkspaceLaunchNewInstance + configuration:conf + error:&error]; + if (!launched) { + LOG(("Translocation Error: relaunch failed: %1" + ).arg(Platform::NS2QString([error localizedDescription]))); + } + return launched != nil; +} + +// Runs before Qt exists, so this is a bare AppKit alert. No localization +// is loaded at this point either. +void ShowTranslocationError() { + NSApplication *app = [NSApplication sharedApplication]; + [app setActivationPolicy:NSApplicationActivationPolicyRegular]; + [app activateIgnoringOtherApps:YES]; + + NSAlert *alert = [[NSAlert alloc] init]; + alert.alertStyle = NSAlertStyleCritical; + alert.messageText = @"Telegram Desktop can't start from here"; + alert.informativeText = @"macOS started this copy of Telegram Desktop " + @"from a read-only temporary location (App Translocation), so it " + @"can't use its own folder. Please reinstall the app and launch it " + @"again."; + [alert addButtonWithTitle:@"Quit"]; + [alert runModal]; + [alert release]; +} + +} // namespace +#endif // !OS_MAC_STORE + namespace Platform { void start() { @@ -117,6 +269,57 @@ void finish() { objc_finish(); } +// macOS starts a quarantined bundle that was never moved by Finder from +// a random read-only mount, so a portable build loses the +// TelegramForcePortable folder it was shipped with and silently works on +// the default installation's data. When the original location is known +// and is the shipped portable layout, stripping the quarantine attribute +// there is exactly what a Finder move would have done (Gatekeeper has +// already assessed and allowed this launch), and the original can be +// relaunched in place. Anything else is a hard stop: moving the parent +// folder does not help and the user has to reinstall. +bool CheckAppTranslocation() { +#ifdef OS_MAC_STORE + // Installed by the App Store: never quarantined, never translocated, + // and the private SecTranslocate symbols must not appear in a store + // binary. + return true; +#else // OS_MAC_STORE + @autoreleasepool { + + const auto bundle = cExeDir() + cExeName(); + if (cExeName().isEmpty()) { + return true; + } + const auto state = CheckTranslocationState(bundle); + if (!state.translocated) { + return true; + } + LOG(("Translocation Info: running from '%1', original '%2'." + ).arg(bundle + ).arg(state.original)); + + const auto relaunched = Core::Launcher::Instance().arguments().contains( + QString::fromLatin1(kUntranslocatedArgument)); + const auto portable = !state.original.isEmpty() + && QFileInfo(state.original + u"/Contents/Info.plist"_q).isFile() + && QDir(QFileInfo(state.original).path() + + u"/TelegramForcePortable"_q).exists(); + if (!relaunched + && portable + && RemoveQuarantineRecursively(state.original) + && RelaunchUntranslocated(state.original)) { + LOG(("Translocation Info: relaunched from '%1'." + ).arg(state.original)); + return false; + } + ShowTranslocationError(); + return false; + + } +#endif // !OS_MAC_STORE +} + QString SingleInstanceLocalServerName(const QString &hash) { #ifndef OS_MAC_STORE return u"/tmp/"_q + hash + '-' + cGUIDStr(); diff --git a/Telegram/SourceFiles/platform/platform_specific.h b/Telegram/SourceFiles/platform/platform_specific.h index c89ba18080..50272555fe 100644 --- a/Telegram/SourceFiles/platform/platform_specific.h +++ b/Telegram/SourceFiles/platform/platform_specific.h @@ -20,6 +20,16 @@ namespace Platform { void start(); void finish(); +// Passed to the instance relaunched from its original location after an +// App Translocation fix, so a relaunch that is still translocated stops +// instead of trying again. +inline constexpr auto kUntranslocatedArgument = "-untranslocated"; + +// Returns false when startup must stop right away: the process was +// started by macOS from a read-only translocated copy of the bundle and +// either relaunched itself from the original location or told the user. +[[nodiscard]] bool CheckAppTranslocation(); + enum class PermissionStatus { Granted, CanRequest, diff --git a/Telegram/SourceFiles/platform/win/specific_win.h b/Telegram/SourceFiles/platform/win/specific_win.h index 6414a4f014..adeeb6b8d8 100644 --- a/Telegram/SourceFiles/platform/win/specific_win.h +++ b/Telegram/SourceFiles/platform/win/specific_win.h @@ -13,6 +13,10 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL namespace Platform { +inline bool CheckAppTranslocation() { + return true; +} + inline void IgnoreApplicationActivationRightNow() { } diff --git a/Telegram/build/canary_publish.sh b/Telegram/build/canary_publish.sh index 87fa79965f..c1964bdd9f 100755 --- a/Telegram/build/canary_publish.sh +++ b/Telegram/build/canary_publish.sh @@ -26,7 +26,9 @@ # BASE, COUNTER, COMMIT the version being published # VERSION_STR the display version (7.0.9) of the archives # PREVIOUS commit of the previous run, for the changelog -# UNSIGNED "true" when platform signing was skipped +# SIGNED "true" when the platform binaries carry their +# Authenticode signature / notarization (Linux +# has none to carry and always passes "true") # KEYS_LOC directory with manifest.min.json + manifest.sig # UPDATE_DIR, PORTABLE_DIR # downloaded artifacts of this platform @@ -109,10 +111,17 @@ if [ ! -f "$PORTABLE" ]; then exit 1 fi +NOTE="" +if [ "$SIGNED" != "true" ]; then + case "$FIRST" in + win64) NOTE="UNSIGNED test build: no Authenticode signature." ;; + mac|armac) NOTE="UNSIGNED test build: not signed or notarized." ;; + esac +fi CAPTION=$({ echo "Canary #$COUNTER ยท $COMMIT" - if [ "$UNSIGNED" = "true" ]; then - echo "UNSIGNED test build: no Authenticode / notarization." + if [ -n "$NOTE" ]; then + echo "$NOTE" fi echo "" if [ -n "$PREVIOUS" ] && git cat-file -e "$PREVIOUS^{commit}" 2>/dev/null \