Fix translocated launch on macOS.

This commit is contained in:
John Preston
2026-08-21 14:12:33 +04:00
parent e5f1deb5e6
commit 3d3aee9f32
7 changed files with 255 additions and 6 deletions

View File

@@ -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

View File

@@ -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<QByteArray, KeyFormat> {
{ "-debug" , KeyFormat::NoValues },
{ "-testagent" , KeyFormat::NoValues },
{ Platform::kUntranslocatedArgument, KeyFormat::NoValues },
{ "-key" , KeyFormat::OneValue },
{ "-autostart" , KeyFormat::NoValues },
{ "-fixprevious" , KeyFormat::NoValues },

View File

@@ -11,6 +11,10 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL
namespace Platform {
inline bool CheckAppTranslocation() {
return true;
}
inline void IgnoreApplicationActivationRightNow() {
}

View File

@@ -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 <QtCore/QDirIterator>
#include <QtGui/QDesktopServices>
#include <QtWidgets/QApplication>
#include <cstdlib>
#include <dlfcn.h>
#include <execinfo.h>
#include <sys/mount.h>
#include <sys/sysctl.h>
#include <sys/xattr.h>
@@ -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<SecTranslocateIsTranslocatedURLFn>(
dlsym(security, "SecTranslocateIsTranslocatedURL"))
: nullptr;
const auto createOriginal = security
? reinterpret_cast<SecTranslocateCreateOriginalPathForURLFn>(
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();

View File

@@ -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,

View File

@@ -13,6 +13,10 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL
namespace Platform {
inline bool CheckAppTranslocation() {
return true;
}
inline void IgnoreApplicationActivationRightNow() {
}

View File

@@ -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 \