Harden interaction app<->shell<->webapp.

This commit is contained in:
John Preston
2026-05-15 09:39:18 +04:00
parent 0090e2c7f8
commit c46fe28908
6 changed files with 663 additions and 199 deletions
+173 -41
View File
@@ -45,25 +45,80 @@
verifiedBadge: null,
menuPalette: null
};
const shellToken = TDESKTOP_SHELL_TOKEN_PLACEHOLDER;
const nativeMessageType = 'tdesktop_external_bot_webapp';
const maxPendingEvents = 64;
let iframe = null;
let frameLoaded = false;
let frameUrl = 'about:blank';
let frameGeneration = 0;
let reloadSupported = false;
let reloadTimeout = null;
let viewportScheduled = false;
let resizeObserver = null;
const pendingEvents = [];
function invoke(eventType, eventData) {
if (window.external && window.external.invoke) {
window.external.invoke(JSON.stringify([
eventType,
JSON.stringify(eventData || {})
]));
function normalizeEventData(eventData) {
if (typeof eventData === 'string') {
try {
const parsed = JSON.parse(eventData);
return parsed
&& typeof parsed === 'object'
&& !Array.isArray(parsed)
? parsed
: {};
} catch (e) {
return {};
}
}
return eventData
&& typeof eventData === 'object'
&& !Array.isArray(eventData)
? eventData
: {};
}
function sendToFrame(eventType, eventData) {
function isShellOrigin() {
return window.location.protocol === 'https:'
&& window.location.hostname === 'web.telegram.org'
&& (!window.location.port || window.location.port === '443');
}
function invokeNative(source, eventType, eventData) {
if (!window.external
|| typeof window.external.invoke !== 'function'
|| typeof shellToken !== 'string'
|| !shellToken
|| typeof eventType !== 'string') {
return;
}
if (source === 'shell' && !isShellOrigin()) {
return;
}
window.external.invoke(JSON.stringify({
type: nativeMessageType,
source: source,
token: shellToken,
origin: window.location.origin,
eventType: eventType,
eventData: normalizeEventData(eventData)
}));
}
function invokeShell(eventType, eventData) {
invokeNative('shell', eventType, eventData);
}
function invokeWebApp(eventType, eventData) {
invokeNative('webapp', eventType, eventData);
}
function sendToFrame(eventType, eventData, generation) {
if (!iframe
|| !iframe.contentWindow
|| generation !== frameGeneration) {
return;
}
iframe.contentWindow.postMessage(JSON.stringify({
eventType: eventType,
eventData: eventData || {}
@@ -71,14 +126,19 @@
}
function postToFrame(eventType, eventData) {
const generation = frameGeneration;
if (!iframe || !iframe.contentWindow || !frameLoaded) {
pendingEvents.push({
generation: generation,
eventType: eventType,
eventData: eventData || {}
});
while (pendingEvents.length > maxPendingEvents) {
pendingEvents.shift();
}
return;
}
sendToFrame(eventType, eventData);
sendToFrame(eventType, eventData, generation);
}
function shellPointerPayload(event, extra) {
@@ -102,11 +162,12 @@
if (shellState.blocked
|| shellState.isFullscreen
|| event.defaultPrevented
|| !event.isTrusted
|| event.button !== 0) {
return;
}
closeMenu();
invoke(command, shellPointerPayload(event, extra));
invokeShell(command, shellPointerPayload(event, extra));
event.preventDefault();
}
@@ -117,11 +178,11 @@
&& target.closest('.title-control, #menu')) {
return;
}
beginShellControl('tdesktop_shell_begin_move', event);
beginShellControl('shell_begin_move', event);
}
function beginShellResize(edge, event) {
beginShellControl('tdesktop_shell_begin_resize', event, {
beginShellControl('shell_begin_resize', event, {
edge: edge
});
}
@@ -129,7 +190,9 @@
function flushPendingEvents() {
const pending = pendingEvents.splice(0);
for (const event of pending) {
postToFrame(event.eventType, event.eventData);
if (event.generation === frameGeneration) {
sendToFrame(event.eventType, event.eventData, event.generation);
}
}
}
@@ -497,7 +560,7 @@
return;
}
state.iconRequestGeneration = state.iconGeneration;
invoke('tdesktop_shell_request_button_icon', {
invokeShell('shell_request_button_icon', {
name: state.name
});
}
@@ -585,9 +648,9 @@
if (clickable) {
node.type = 'button';
setupRipple(node);
node.addEventListener('click', function() {
if (!shellState.blocked) {
invoke('tdesktop_shell_menu_action', { id: item.id });
node.addEventListener('click', function(event) {
if (!shellState.blocked && event.isTrusted) {
invokeShell('shell_menu_action', { id: item.id });
closeMenu();
}
});
@@ -692,7 +755,10 @@
renderMenu();
}
function toggleMenu() {
function toggleMenu(event) {
if (event && !event.isTrusted) {
return;
}
if (shellState.blocked) {
return;
}
@@ -700,7 +766,7 @@
closeMenu();
return;
}
invoke('tdesktop_shell_menu_request', {});
invokeShell('shell_menu_request', {});
shellState.menuOpen = true;
renderMenu();
}
@@ -713,7 +779,9 @@
return null;
}
}
return data && typeof data === 'object' ? data : null;
return data && typeof data === 'object' && !Array.isArray(data)
? data
: null;
}
function addRipple(button, x, y) {
@@ -764,23 +832,27 @@
}
window.addEventListener('message', function(event) {
if (!iframe || event.source !== iframe.contentWindow) {
if (!iframe
|| !iframe.contentWindow
|| event.source !== iframe.contentWindow) {
return;
}
const message = parseFrameMessage(event.data);
if (!message || !message.eventType) {
if (!message || typeof message.eventType !== 'string') {
return;
}
if (message.eventType === 'iframe_ready') {
reloadSupported = !!(message.eventData && message.eventData.reload_supported);
return;
} else if (message.eventType === 'iframe_will_reload') {
if (reloadTimeout) {
window.clearTimeout(reloadTimeout);
reloadTimeout = null;
}
frameLoaded = false;
return;
}
invoke(message.eventType, message.eventData || {});
invokeWebApp(message.eventType, message.eventData);
});
menuBackdrop.addEventListener('mousedown', closeMenu);
@@ -812,8 +884,10 @@
resizeObserver.observe(root);
}
controls.close.addEventListener('click', function() {
invoke('tdesktop_shell_close', {});
controls.close.addEventListener('click', function(event) {
if (event.isTrusted) {
invokeShell('shell_close', {});
}
});
controls.back.addEventListener('click', function() {
postToFrame('back_button_pressed', {});
@@ -838,10 +912,13 @@
window.clearTimeout(reloadTimeout);
reloadTimeout = null;
}
const generation = ++frameGeneration;
pendingEvents.splice(0);
const next = document.createElement('iframe');
next.setAttribute('allow', 'clipboard-read; clipboard-write; fullscreen');
next.referrerPolicy = 'no-referrer';
next.addEventListener('load', function() {
if (iframe !== next) {
if (iframe !== next || generation !== frameGeneration) {
return;
}
frameLoaded = true;
@@ -867,7 +944,7 @@
return;
}
if (reloadSupported && frameLoaded && iframe.contentWindow) {
sendToFrame('reload_iframe', {});
sendToFrame('reload_iframe', {}, frameGeneration);
if (reloadTimeout) {
window.clearTimeout(reloadTimeout);
}
@@ -880,8 +957,15 @@
fallbackReloadFrame();
}
window.TelegramDesktopShell = {
bootstrap: function(data) {
function isNativeToken(token) {
return !!shellToken && token === shellToken;
}
const api = {
bootstrap: function(data, token) {
if (!isNativeToken(token)) {
return;
}
applyMetrics(data && data.metrics);
applyColors(data && data.colors);
applyChrome(data || {});
@@ -893,7 +977,10 @@
renderButtons();
renderMenu();
},
nativeEvent: function(eventType, eventData) {
nativeEvent: function(eventType, eventData, token) {
if (!isNativeToken(token) || typeof eventType !== 'string') {
return;
}
if (eventType === 'fullscreen_changed' && eventData) {
shellState.isFullscreen = !!eventData.is_fullscreen;
root.classList.toggle('fullscreen', shellState.isFullscreen);
@@ -901,31 +988,52 @@
}
postToFrame(eventType, eventData || {});
},
setTitle: function(data) {
setTitle: function(data, token) {
if (!isNativeToken(token)) {
return;
}
title.textContent = (data && data.title) || '';
document.title = (data && data.title) || 'Telegram';
},
setChrome: function(data) {
setChrome: function(data, token) {
if (!isNativeToken(token)) {
return;
}
applyChrome(data || {});
},
setColors: function(data) {
setColors: function(data, token) {
if (!isNativeToken(token)) {
return;
}
applyColors(data || {});
},
setAssets: function(data) {
setAssets: function(data, token) {
if (!isNativeToken(token)) {
return;
}
applyAssets(data || {});
},
setMenu: function(data) {
setMenu: function(data, token) {
if (!isNativeToken(token)) {
return;
}
shellState.menuItems = Array.isArray(data && data.items)
? data.items
: [];
applyChrome({});
renderMenu();
},
setBottomText: function(data) {
setBottomText: function(data, token) {
if (!isNativeToken(token)) {
return;
}
shellState.bottomText = '';
updateFooter();
},
setButton: function(data) {
setButton: function(data, token) {
if (!isNativeToken(token)) {
return;
}
if (!data || !data.name) {
return;
}
@@ -945,7 +1053,10 @@
shellState.buttons[data.name] = next;
renderButtons();
},
setButtonIcon: function(data) {
setButtonIcon: function(data, token) {
if (!isNativeToken(token)) {
return;
}
if (!data || !data.name) {
return;
}
@@ -961,17 +1072,38 @@
state.iconUrl = (icon && icon.url) ? icon.url : '';
renderButtons();
},
setBlocked: function(data) {
setBlocked: function(data, token) {
if (!isNativeToken(token)) {
return;
}
shellState.blocked = !!(data && data.blocked);
root.classList.toggle('blocked', shellState.blocked);
if (shellState.blocked) {
closeMenu();
}
},
setProgress: function(data) {
setProgress: function(data, token) {
if (!isNativeToken(token)) {
return;
}
root.classList.toggle('loading', !!(data && data.shown));
},
reloadFrame: reloadFrame,
sendViewport: scheduleViewport
reloadFrame: function(data, token) {
if (!isNativeToken(token)) {
return;
}
reloadFrame();
},
sendViewport: function(data, token) {
if (!isNativeToken(token)) {
return;
}
scheduleViewport();
}
};
Object.defineProperty(window, 'TelegramDesktopShell', {
value: Object.freeze(api),
configurable: false,
writable: false
});
})();
@@ -36,6 +36,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL
#include "base/invoke_queued.h"
#include "base/options.h"
#include "base/qt_signal_producer.h"
#include "base/random.h"
#include "styles/style_chat.h"
#include "styles/style_info.h"
#include "styles/style_payments.h"
@@ -46,6 +47,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL
#include <QtCore/QJsonDocument>
#include <QtCore/QJsonObject>
#include <QtCore/QJsonArray>
#include <QtCore/QUrl>
#include <QtGui/QGuiApplication>
#include <QtGui/QClipboard>
#include <QtGui/QWindow>
@@ -66,6 +68,43 @@ constexpr auto kProgressOpacity = 0.3;
constexpr auto kLightnessThreshold = 128;
constexpr auto kLightnessDelta = 32;
constexpr auto kExternalShellButtonIconSize = 20;
constexpr auto kMaxNativeMessageBytes = 1024 * 1024;
constexpr auto kExternalMessageType = "tdesktop_external_bot_webapp";
enum class NativeMessageSource {
LegacyWebApp,
ExternalWebApp,
ExternalShell,
};
struct NativeMessage {
NativeMessageSource source = NativeMessageSource::LegacyWebApp;
QString command;
QJsonObject arguments;
};
[[nodiscard]] QString GenerateExternalShellToken() {
auto bytes = QByteArray();
bytes.resize(32);
base::RandomFill(bytes.data(), bytes.size());
return QString::fromLatin1(bytes.toHex());
}
[[nodiscard]] QString ExternalShellTopUrl() {
return u"https://web.telegram.org:443/blank.html"_q;
}
[[nodiscard]] bool IsExternalShellOrigin(const QString &origin) {
const auto url = QUrl(origin);
return url.isValid()
&& url.scheme() == u"https"_q
&& url.host() == u"web.telegram.org"_q
&& url.port(443) == 443
&& url.userInfo().isEmpty()
&& url.path().isEmpty()
&& url.query().isEmpty()
&& url.fragment().isEmpty();
}
base::options::toggle OptionLinuxExternalBotWebApps({
.id = kOptionLinuxExternalBotWebApps,
@@ -89,17 +128,181 @@ base::options::toggle OptionLinuxExternalBotWebApps({
return RectPart::Left;
}
[[nodiscard]] QJsonObject ParseMethodArgs(const QString &json) {
if (json.isEmpty()) {
[[nodiscard]] bool CanParseArguments(QJsonValue value) {
if (value.isObject()) {
return true;
} else if (!value.isString()) {
return false;
}
auto error = QJsonParseError();
const auto document = QJsonDocument::fromJson(
value.toString().toUtf8(),
&error);
return (error.error == QJsonParseError::NoError) && document.isObject();
}
[[nodiscard]] QJsonObject ParseArguments(QJsonValue value) {
if (value.isObject()) {
return value.toObject();
} else if (!value.isString()) {
return {};
}
auto error = QJsonParseError();
const auto dictionary = QJsonDocument::fromJson(json.toUtf8(), &error);
if (error.error != QJsonParseError::NoError) {
LOG(("BotWebView Error: Could not parse \"%1\".").arg(json));
const auto document = QJsonDocument::fromJson(
value.toString().toUtf8(),
&error);
return (error.error == QJsonParseError::NoError && document.isObject())
? document.object()
: QJsonObject();
}
[[nodiscard]] bool IsShellNamespaceCommand(const QString &command) {
return command.startsWith(u"shell_"_q)
|| command.startsWith(u"tdesktop_shell_"_q);
}
[[nodiscard]] QString SafeCommandForLog(const QString &command) {
if (command.isEmpty() || command.size() > 80) {
return {};
}
return dictionary.object();
if (!command.startsWith(u"web_app_"_q)
&& !command.startsWith(u"shell_"_q)
&& !command.startsWith(u"tdesktop_shell_"_q)
&& command != u"share_score"_q) {
return {};
}
const auto safe = std::all_of(
command.begin(),
command.end(),
[](QChar ch) {
return ch.isLetterOrNumber() || ch == QChar('_');
});
return safe ? command : QString();
}
void LogNativeMessageRejected(
const QString &reason,
quint64 bytes,
const QString &command = QString()) {
const auto safeCommand = SafeCommandForLog(command);
if (safeCommand.isEmpty()) {
LOG(("BotWebView Error: Native message rejected: %1 (%2 bytes)."
).arg(reason, QString::number(bytes)));
} else {
LOG(("BotWebView Error: Native message rejected: %1 "
"(%2 bytes, command: %3)."
).arg(reason, QString::number(bytes), safeCommand));
}
}
[[nodiscard]] std::optional<NativeMessage> ParseNativeMessage(
const QByteArray &bytes,
bool externalShell,
const QString &shellToken) {
const auto byteCount = quint64(bytes.size());
if (bytes.size() > kMaxNativeMessageBytes) {
LogNativeMessageRejected(u"payload too large"_q, byteCount);
return std::nullopt;
}
auto error = QJsonParseError();
const auto document = QJsonDocument::fromJson(bytes, &error);
if (error.error != QJsonParseError::NoError) {
LogNativeMessageRejected(u"invalid json"_q, byteCount);
return std::nullopt;
}
if (externalShell) {
if (!document.isObject()) {
LogNativeMessageRejected(
u"external payload is not an object"_q,
byteCount);
return std::nullopt;
}
const auto object = document.object();
const auto command = object.value(u"eventType"_q).toString();
const auto reject = [&](const QString &reason) {
LogNativeMessageRejected(reason, byteCount, command);
return std::optional<NativeMessage>();
};
if (object.value(u"type"_q).toString()
!= QString::fromLatin1(kExternalMessageType)) {
return reject(u"bad external type"_q);
}
const auto sourceText = object.value(u"source"_q).toString();
auto source = NativeMessageSource::ExternalWebApp;
if (sourceText == u"webapp"_q) {
source = NativeMessageSource::ExternalWebApp;
} else if (sourceText == u"shell"_q) {
source = NativeMessageSource::ExternalShell;
} else {
return reject(u"bad external source"_q);
}
const auto token = object.value(u"token"_q);
if (!token.isString()
|| shellToken.isEmpty()
|| token.toString() != shellToken) {
return reject(u"bad external token"_q);
}
if (source == NativeMessageSource::ExternalShell) {
const auto origin = object.value(u"origin"_q);
if (!origin.isString()
|| !IsExternalShellOrigin(origin.toString())) {
return reject(u"bad shell origin"_q);
}
}
if (!object.value(u"eventType"_q).isString() || command.isEmpty()) {
return reject(u"bad command"_q);
}
const auto data = object.value(u"eventData"_q);
if (!CanParseArguments(data)) {
return reject(u"bad arguments"_q);
}
const auto arguments = ParseArguments(data);
const auto shellCommand = IsShellNamespaceCommand(command);
if (source == NativeMessageSource::ExternalShell) {
if (!shellCommand) {
return reject(u"non-shell command from shell"_q);
}
} else if (shellCommand) {
return reject(u"shell command from webapp"_q);
}
return NativeMessage{
.source = source,
.command = command,
.arguments = arguments,
};
}
if (!document.isArray()) {
LogNativeMessageRejected(
u"legacy payload is not an array"_q,
byteCount);
return std::nullopt;
}
const auto list = document.array();
const auto command = list.at(0).toString();
const auto reject = [&](const QString &reason) {
LogNativeMessageRejected(reason, byteCount, command);
return std::optional<NativeMessage>();
};
if (!list.at(0).isString() || command.isEmpty()) {
return reject(u"bad command"_q);
} else if (IsShellNamespaceCommand(command)) {
return reject(u"shell command from legacy"_q);
}
auto arguments = QJsonObject();
if (list.size() > 1) {
const auto value = list.at(1);
if (!value.isNull()
&& !value.isUndefined()
&& !CanParseArguments(value)) {
return reject(u"bad arguments"_q);
}
arguments = ParseArguments(value);
}
return NativeMessage{
.source = NativeMessageSource::LegacyWebApp,
.command = command,
.arguments = arguments,
};
}
[[nodiscard]] bool UseExternalBotWebApps() {
@@ -112,6 +315,12 @@ base::options::toggle OptionLinuxExternalBotWebApps({
return (color.alpha() == 255) ? color : st::windowBg->c;
}
[[nodiscard]] QJsonObject ThemeChangedPayload(
const Webview::ThemeParams &params) {
const auto parsed = QJsonDocument::fromJson(params.json);
return { { u"theme_params"_q, parsed.object() } };
}
enum class SharedPanelMenuAction {
None,
Settings,
@@ -1262,6 +1471,9 @@ void Panel::hideWebviewProgress() {
bool Panel::showWebview(Args &&args, const Webview::ThemeParams &params) {
_bottomText = std::move(args.bottom);
_externalUrl = args.url;
if (_externalShell && !_webview) {
resetExternalShellIdentity();
}
if (!_webview && !createWebview(params)) {
return false;
}
@@ -1274,8 +1486,7 @@ bool Panel::showWebview(Args &&args, const Webview::ThemeParams &params) {
const auto url = args.url;
if (_externalShell) {
_externalShellBootstrapped = false;
_webview->window.navigate(
u"https://web.telegram.org/blank.html"_q);
_webview->window.navigate(ExternalShellTopUrl());
} else {
_webview->window.navigate(url);
_widget->setBackAllowed(allowBack);
@@ -1390,11 +1601,20 @@ void Panel::sendExternalShellColors(const Webview::ThemeParams &params) {
LinuxShell::ColorPayload(externalShellColors(params)));
}
void Panel::resetExternalShellIdentity() {
_externalShellToken = GenerateExternalShellToken();
++_externalShellGeneration;
_externalShellBootstrapped = false;
}
void Panel::installExternalShellDocument() {
if (!_webview) {
if (!_externalShell
|| !_externalShellBootstrapped
|| !_webview
|| _externalShellToken.isEmpty()) {
return;
}
_webview->window.eval(LinuxShell::InstallScript());
_webview->window.eval(LinuxShell::InstallScript(_externalShellToken));
}
void Panel::sendExternalShellBootstrap() {
@@ -1411,7 +1631,7 @@ void Panel::sendExternalShellBootstrap() {
});
sendExternalShellAssets();
sendExternalShellMenu();
postEvent("theme_changed", "{\"theme_params\": " + params.json + "}");
postEvent("theme_changed", ThemeChangedPayload(params));
sendFullScreen();
sendSafeArea();
sendContentSafeArea();
@@ -1420,19 +1640,27 @@ void Panel::sendExternalShellBootstrap() {
void Panel::sendExternalShellMethod(
const QByteArray &method,
const QJsonObject &data) {
if (!_webview) {
if (!_externalShell
|| !_externalShellBootstrapped
|| !_webview
|| _externalShellToken.isEmpty()) {
return;
}
_webview->window.eval(LinuxShell::MethodCallScript(method, data));
_webview->window.eval(
LinuxShell::MethodCallScript(method, data, _externalShellToken));
}
void Panel::sendExternalShellEvent(
const QString &event,
const QByteArray &data) {
if (!_webview) {
const QJsonObject &data) {
if (!_externalShell
|| !_externalShellBootstrapped
|| !_webview
|| _externalShellToken.isEmpty()) {
return;
}
_webview->window.eval(LinuxShell::EventScript(event, data));
_webview->window.eval(
LinuxShell::EventScript(event, data, _externalShellToken));
}
void Panel::sendExternalShellButton(
@@ -1591,13 +1819,15 @@ void Panel::handleExternalShellMenuAction(const QString &id) {
.reload = [=] {
if (_webview && _webview->window.widget()) {
sendExternalShellMethod("reloadFrame", {});
} else if (const auto params = _delegate->botThemeParams()
; createWebview(params)) {
} else {
const auto params = _delegate->botThemeParams();
resetExternalShellIdentity();
if (!createWebview(params)) {
return;
}
showWebviewProgress();
updateThemeParams(params);
_externalShellBootstrapped = false;
_webview->window.navigate(
u"https://web.telegram.org/blank.html"_q);
_webview->window.navigate(ExternalShellTopUrl());
}
},
.terms = [=] {
@@ -1669,6 +1899,11 @@ Panel::ExternalShellAnchor Panel::externalShellAnchor() const {
return result;
}
QWidget *Panel::webviewWindowForPopup() const {
const auto widget = _webview ? _webview->window.widget() : nullptr;
return widget ? widget->window() : nullptr;
}
Webview::PopupResult Panel::showBlockingPopup(Webview::PopupArgs &&args) {
if (!_externalShell) {
return Webview::ShowBlockingPopup(std::move(args));
@@ -1757,12 +1992,19 @@ bool Panel::createWebview(const Webview::ThemeParams &params) {
.windowMargins = _externalShell
? st::botWebViewShellShadowPadding
: QMargins(),
.shellMessageToken = _externalShell
? _externalShellToken
: QString(),
});
const auto raw = &_webview->window;
const auto bottom = _webviewBottom.get();
QObject::connect(container, &QObject::destroyed, [=] {
if (_webview && &_webview->window == raw) {
if (_externalShell) {
_externalShellBootstrapped = false;
++_externalShellGeneration;
}
base::take(_webview);
if (_webviewProgress) {
hideWebviewProgress();
@@ -1805,6 +2047,10 @@ bool Panel::createWebview(const Webview::ThemeParams &params) {
// we don't show any message, nothing crashed.
return;
}
if (_externalShell) {
_externalShellBootstrapped = false;
++_externalShellGeneration;
}
crl::on_main(this, [=] {
if (_externalShell) {
_delegate->botClose();
@@ -1832,30 +2078,54 @@ bool Panel::createWebview(const Webview::ThemeParams &params) {
}, _webview->lifetime);
}
raw->setMessageHandler([=](const QJsonDocument &message) {
if (!message.isArray()) {
LOG(("BotWebView Error: "
"Not an array received in buy_callback arguments."));
raw->setMessageHandler([=](std::string text) {
if (text.size() > size_t(kMaxNativeMessageBytes)) {
LogNativeMessageRejected(
u"payload too large"_q,
quint64(text.size()));
return;
}
const auto bytes = QByteArray::fromRawData(
text.data(),
int(text.size()));
const auto parsed = ParseNativeMessage(
bytes,
_externalShell,
_externalShellToken);
if (!parsed) {
return;
}
const auto &command = parsed->command;
const auto &arguments = parsed->arguments;
if (parsed->source == NativeMessageSource::ExternalShell) {
if (!_externalShell || !_externalShellBootstrapped) {
return;
}
if (command == "shell_close") {
if (_closeNeedConfirmation) {
scheduleCloseWithConfirmation();
} else {
_delegate->botClose();
}
} else if (command == "shell_menu_request") {
if (_externalBlockCount <= 0) {
sendExternalShellAssets();
sendExternalShellMenu();
}
} else if (command == "shell_menu_action") {
if (_externalBlockCount <= 0) {
handleExternalShellMenuAction(arguments["id"].toString());
}
} else if (command == "shell_request_button_icon") {
const auto name = arguments["name"];
if (name.isString()) {
requestExternalShellButtonEmoji(name.toString());
}
}
return;
}
const auto list = message.array();
const auto command = list.at(0).toString();
const auto arguments = ParseMethodArgs(list.at(1).toString());
if (command == "web_app_close") {
_delegate->botClose();
} else if (command == "tdesktop_shell_close") {
if (_closeNeedConfirmation) {
scheduleCloseWithConfirmation();
} else {
_delegate->botClose();
}
} else if (command == "tdesktop_shell_menu_request") {
sendExternalShellAssets();
sendExternalShellMenu();
} else if (command == "tdesktop_shell_menu_action") {
handleExternalShellMenuAction(arguments["id"].toString());
} else if (command == "tdesktop_shell_request_button_icon") {
requestExternalShellButtonEmoji(arguments["name"].toString());
} else if (command == "web_app_data_send") {
sendDataMessage(arguments);
} else if (command == "web_app_switch_inline_query") {
@@ -1891,21 +2161,33 @@ bool Panel::createWebview(const Webview::ThemeParams &params) {
sendFullScreen();
}
} else if (command == "web_app_check_home_screen") {
postEvent("home_screen_checked", "{ status: \"unsupported\" }");
postEvent("home_screen_checked", QJsonObject{
{ u"status"_q, u"unsupported"_q },
});
} else if (command == "web_app_start_accelerometer") {
postEvent("accelerometer_failed", "{ error: \"UNSUPPORTED\" }");
postEvent("accelerometer_failed", QJsonObject{
{ u"error"_q, u"UNSUPPORTED"_q },
});
} else if (command == "web_app_start_device_orientation") {
postEvent(
"device_orientation_failed",
"{ error: \"UNSUPPORTED\" }");
postEvent("device_orientation_failed", QJsonObject{
{ u"error"_q, u"UNSUPPORTED"_q },
});
} else if (command == "web_app_start_gyroscope") {
postEvent("gyroscope_failed", "{ error: \"UNSUPPORTED\" }");
postEvent("gyroscope_failed", QJsonObject{
{ u"error"_q, u"UNSUPPORTED"_q },
});
} else if (command == "web_app_check_location") {
postEvent("location_checked", "{ available: false }");
postEvent("location_checked", QJsonObject{
{ u"available"_q, false },
});
} else if (command == "web_app_request_location") {
postEvent("location_requested", "{ available: false }");
postEvent("location_requested", QJsonObject{
{ u"available"_q, false },
});
} else if (command == "web_app_biometry_get_info") {
postEvent("biometry_info_received", "{ available: false }");
postEvent("biometry_info_received", QJsonObject{
{ u"available"_q, false },
});
} else if (command == "web_app_open_tg_link") {
openTgLink(arguments);
} else if (command == "web_app_open_link") {
@@ -2059,14 +2341,18 @@ void Panel::sendViewport() {
}
void Panel::sendFullScreen() {
postEvent("fullscreen_changed", _fullscreen.current()
? "{ is_fullscreen: true }"
: "{ is_fullscreen: false }");
postEvent("fullscreen_changed", QJsonObject{
{ u"is_fullscreen"_q, _fullscreen.current() },
});
}
void Panel::sendSafeArea() {
postEvent("safe_area_changed",
"{ top: 0, right: 0, bottom: 0, left: 0 }");
postEvent("safe_area_changed", QJsonObject{
{ u"top"_q, 0 },
{ u"right"_q, 0 },
{ u"bottom"_q, 0 },
{ u"left"_q, 0 },
});
}
void Panel::sendContentSafeArea() {
@@ -2084,8 +2370,12 @@ void Panel::sendContentSafeArea() {
const auto systemScreenScale = dpi * ratio / base;
report = int(base::SafeRound(scaled / systemScreenScale));
}
postEvent("content_safe_area_changed",
u"{ top: %1, right: 0, bottom: 0, left: 0 }"_q.arg(report));
postEvent("content_safe_area_changed", QJsonObject{
{ u"top"_q, report },
{ u"right"_q, 0 },
{ u"bottom"_q, 0 },
{ u"left"_q, 0 },
});
}
void Panel::setTitle(rpl::producer<QString> title) {
@@ -2155,9 +2445,9 @@ void Panel::processSendMessageRequest(const QJsonObject &args) {
if (error.isEmpty()) {
postEvent("prepared_message_sent");
} else {
postEvent(
"prepared_message_failed",
u"{ error: \"%1\" }"_q.arg(error));
postEvent("prepared_message_failed", QJsonObject{
{ u"error"_q, error },
});
}
});
_delegate->botSendPreparedMessage({
@@ -2177,15 +2467,14 @@ void Panel::processRequestChat(const QJsonObject &args) {
}
auto callback = crl::guard(this, [=](QString error) {
if (error.isEmpty()) {
postEvent(
"requested_chat_sent",
u"{ req_id: \"%1\" }"_q.arg(requestId));
postEvent("requested_chat_sent", QJsonObject{
{ u"req_id"_q, requestId },
});
} else {
postEvent(
"requested_chat_failed",
u"{ req_id: \"%1\", error: \"%2\" }"_q.arg(
requestId,
error));
postEvent("requested_chat_failed", QJsonObject{
{ u"req_id"_q, requestId },
{ u"error"_q, error },
});
}
});
_delegate->botRequestChat({
@@ -2203,23 +2492,23 @@ void Panel::processEmojiStatusRequest(const QJsonObject &args) {
const auto duration = TimeId(base::SafeRound(
args["duration"].toDouble()));
if (!emojiId) {
postEvent(
"emoji_status_failed",
"{ error: \"SUGGESTED_EMOJI_INVALID\" }");
postEvent("emoji_status_failed", QJsonObject{
{ u"error"_q, u"SUGGESTED_EMOJI_INVALID"_q },
});
return;
} else if (duration < 0) {
postEvent(
"emoji_status_failed",
"{ error: \"DURATION_INVALID\" }");
postEvent("emoji_status_failed", QJsonObject{
{ u"error"_q, u"DURATION_INVALID"_q },
});
return;
}
auto callback = crl::guard(this, [=](QString error) {
if (error.isEmpty()) {
postEvent("emoji_status_set");
} else {
postEvent(
"emoji_status_failed",
u"{ error: \"%1\" }"_q.arg(error));
postEvent("emoji_status_failed", QJsonObject{
{ u"error"_q, error },
});
}
});
_delegate->botSetEmojiStatus({
@@ -2231,9 +2520,9 @@ void Panel::processEmojiStatusRequest(const QJsonObject &args) {
void Panel::processEmojiStatusAccessRequest() {
auto callback = crl::guard(this, [=](bool allowed) {
postEvent("emoji_status_access_requested", allowed
? "{ status: \"allowed\" }"
: "{ status: \"cancelled\" }");
postEvent("emoji_status_access_requested", QJsonObject{
{ u"status"_q, allowed ? u"allowed"_q : u"cancelled"_q },
});
});
_delegate->botRequestEmojiStatusAccess(std::move(callback));
}
@@ -2384,10 +2673,9 @@ void Panel::openPopup(const QJsonObject &args) {
_delegate->botClose();
return;
}
const auto widget = _webview->window.widget();
const auto weak = base::make_weak(this);
const auto result = showBlockingPopup({
.parent = widget ? widget->window() : nullptr,
.parent = webviewWindowForPopup(),
.title = args["title"].toString(),
.text = message,
.buttons = std::move(buttons),
@@ -2400,9 +2688,8 @@ void Panel::openPopup(const QJsonObject &args) {
}
void Panel::openScanQrPopup(const QJsonObject &args) {
const auto widget = _webview->window.widget();
[[maybe_unused]] const auto ok = showBlockingPopup({
.parent = widget ? widget->window() : nullptr,
.parent = webviewWindowForPopup(),
.text = tr::lng_bot_no_scan_qr(tr::now),
.buttons = { {
.id = "ok",
@@ -2413,9 +2700,8 @@ void Panel::openScanQrPopup(const QJsonObject &args) {
}
void Panel::openShareStory(const QJsonObject &args) {
const auto widget = _webview->window.widget();
[[maybe_unused]] const auto ok = showBlockingPopup({
.parent = widget ? widget->window() : nullptr,
.parent = webviewWindowForPopup(),
.text = tr::lng_bot_no_share_story(tr::now),
.buttons = { {
.id = "ok",
@@ -2444,10 +2730,13 @@ void Panel::requestWriteAccess() {
return;
}
using Button = Webview::PopupArgs::Button;
const auto widget = _webview->window.widget();
if (!_webview) {
_inBlockingRequest = false;
return;
}
const auto integration = &Ui::Integration::Instance();
const auto result = showBlockingPopup({
.parent = widget ? widget->window() : nullptr,
.parent = webviewWindowForPopup(),
.title = integration->phraseBotAllowWriteTitle(),
.text = integration->phraseBotAllowWrite(),
.buttons = {
@@ -2485,11 +2774,14 @@ void Panel::requestPhone() {
replyRequestPhone(shared);
};
using Button = Webview::PopupArgs::Button;
const auto widget = _webview->window.widget();
const auto weak = base::make_weak(this);
if (!_webview) {
_inBlockingRequest = false;
return;
}
const auto integration = &Ui::Integration::Instance();
const auto result = showBlockingPopup({
.parent = widget ? widget->window() : nullptr,
.parent = webviewWindowForPopup(),
.title = integration->phraseBotSharePhoneTitle(),
.text = integration->phraseBotSharePhone(),
.buttons = {
@@ -2590,17 +2882,28 @@ bool Panel::allowClipboardQuery() const {
void Panel::scheduleCloseWithConfirmation() {
if (!_closeWithConfirmationScheduled) {
_closeWithConfirmationScheduled = true;
InvokeQueued(_widget.get(), [=] { closeWithConfirmation(); });
const auto generation = _externalShellGeneration;
InvokeQueued(_widget.get(), [=] {
if (_externalShell && generation != _externalShellGeneration) {
_closeWithConfirmationScheduled = false;
return;
}
closeWithConfirmation();
});
}
}
void Panel::closeWithConfirmation() {
if (!_webview) {
_closeWithConfirmationScheduled = false;
_delegate->botClose();
return;
}
using Button = Webview::PopupArgs::Button;
const auto widget = _webview->window.widget();
const auto weak = base::make_weak(this);
const auto integration = &Ui::Integration::Instance();
const auto result = showBlockingPopup({
.parent = widget ? widget->window() : nullptr,
.parent = webviewWindowForPopup(),
.title = integration->phrasePanelCloseWarning(),
.text = integration->phrasePanelCloseUnsaved(),
.buttons = {
@@ -2845,9 +3148,9 @@ void Panel::processDownloadRequest(const QJsonObject &args) {
return;
}
const auto done = crl::guard(this, [=](bool started) {
postEvent("file_download_requested", started
? "{ status: \"downloading\" }"
: "{ status: \"cancelled\" }");
postEvent("file_download_requested", QJsonObject{
{ u"status"_q, started ? u"downloading"_q : u"cancelled"_q },
});
});
_delegate->botDownloadFile({
.url = url,
@@ -3124,7 +3427,7 @@ void Panel::updateThemeParams(const Webview::ThemeParams &params) {
params.scrollBarBgOver);
sendExternalShellColors(params);
sendExternalShellAssets();
postEvent("theme_changed", "{\"theme_params\": " + params.json + "}");
postEvent("theme_changed", ThemeChangedPayload(params));
return;
}
_webview->window.updateTheme(
@@ -3186,14 +3489,21 @@ void Panel::postEvent(const QString &event, EventData data) {
).arg(event));
return;
}
if (_externalShell) {
if (v::is<QJsonObject>(data)) {
sendExternalShellEvent(event, v::get<QJsonObject>(data));
} else if (v::get<QString>(data).isEmpty()) {
sendExternalShellEvent(event, {});
} else {
LOG(("BotWebView Error: Drop raw external event \"%1\"."
).arg(event));
}
return;
}
auto written = v::is<QString>(data)
? v::get<QString>(data).toUtf8()
: QJsonDocument(
v::get<QJsonObject>(data)).toJson(QJsonDocument::Compact);
if (_externalShell) {
sendExternalShellEvent(event, written);
return;
}
_webview->window.eval(R"(
if (window.TelegramGameProxy) {
window.TelegramGameProxy.receiveEvent(
@@ -218,6 +218,8 @@ private:
bool showWebview(Args &&args, const Webview::ThemeParams &params);
bool createWebview(const Webview::ThemeParams &params);
void resetExternalShellIdentity();
[[nodiscard]] QWidget *webviewWindowForPopup() const;
void installExternalShellDocument();
void sendExternalShellBootstrap();
void sendExternalShellMethod(
@@ -225,7 +227,7 @@ private:
const QJsonObject &data);
void sendExternalShellEvent(
const QString &event,
const QByteArray &data);
const QJsonObject &data);
void sendExternalShellButton(
const char *name,
const QJsonObject &args);
@@ -323,6 +325,8 @@ private:
bool _externalTitleBadgeVisible = false;
bool _externalShell = false;
bool _externalShellBootstrapped = false;
QString _externalShellToken;
uint64 _externalShellGeneration = 0;
bool _externalBackVisible = false;
ExternalShellColorState _externalShellColorState;
MenuButtons _menuButtons = {};
@@ -60,58 +60,62 @@ namespace {
} // namespace
QByteArray InstallScript() {
static const auto result = [] {
const auto css = QString::fromUtf8(PageCss());
const auto body = QString::fromUtf8(BodyHtml());
auto script = QByteArray();
script += "if (window === window.top"
" && !window.TelegramDesktopShell"
" && !window.TelegramDesktopShellInstalling) {"
"window.TelegramDesktopShellInstalling = true;"
"try {"
"if (!document.head) {"
"document.documentElement.insertBefore("
"document.createElement('head'),"
"document.documentElement.firstChild);"
"}"
"if (!document.body) {"
"document.documentElement.appendChild("
"document.createElement('body'));"
"}"
"document.title = 'Telegram';"
"const metaRobots = document.createElement('meta');"
"metaRobots.name = 'robots';"
"metaRobots.content = 'noindex, nofollow';"
"document.head.appendChild(metaRobots);"
"const metaViewport = document.createElement('meta');"
"metaViewport.name = 'viewport';"
"metaViewport.content = 'width=device-width, initial-scale=1.0';"
"document.head.appendChild(metaViewport);"
"const style = document.createElement('style');"
"style.textContent = ";
script += JsonValue(css);
script += ";"
"document.head.appendChild(style);"
"document.body.insertAdjacentHTML('beforeend', ";
script += JsonValue(body);
script += ");";
script += PageJs();
script += "} finally {"
"window.TelegramDesktopShellInstalling = false;"
"}"
"}";
return script;
}();
return result;
QByteArray InstallScript(const QString &shellToken) {
const auto css = QString::fromUtf8(PageCss());
const auto body = QString::fromUtf8(BodyHtml());
auto pageJs = PageJs();
pageJs.replace(
QByteArray("TDESKTOP_SHELL_TOKEN_PLACEHOLDER"),
JsonValue(shellToken));
auto script = QByteArray();
script += "if (window === window.top"
" && !window.TelegramDesktopShell"
" && !window.TelegramDesktopShellInstalling) {"
"window.TelegramDesktopShellInstalling = true;"
"try {"
"if (!document.head) {"
"document.documentElement.insertBefore("
"document.createElement('head'),"
"document.documentElement.firstChild);"
"}"
"if (!document.body) {"
"document.documentElement.appendChild("
"document.createElement('body'));"
"}"
"document.title = 'Telegram';"
"const metaRobots = document.createElement('meta');"
"metaRobots.name = 'robots';"
"metaRobots.content = 'noindex, nofollow';"
"document.head.appendChild(metaRobots);"
"const metaViewport = document.createElement('meta');"
"metaViewport.name = 'viewport';"
"metaViewport.content = 'width=device-width, initial-scale=1.0';"
"document.head.appendChild(metaViewport);"
"const style = document.createElement('style');"
"style.textContent = ";
script += JsonValue(css);
script += ";"
"document.head.appendChild(style);"
"document.body.insertAdjacentHTML('beforeend', ";
script += JsonValue(body);
script += ");";
script += pageJs;
script += "} finally {"
"window.TelegramDesktopShellInstalling = false;"
"}"
"}";
return script;
}
QByteArray MethodCallScript(
const QByteArray &method,
const QJsonObject &data) {
const QJsonObject &data,
const QString &shellToken) {
const auto payload = JsonObject(data);
const auto token = JsonValue(shellToken);
auto script = QByteArray();
script.reserve(method.size() * 2 + payload.size() + 96);
script.reserve(method.size() * 2 + payload.size() + token.size() + 98);
script += "if (window.TelegramDesktopShell"
" && window.TelegramDesktopShell.";
script += method;
@@ -119,17 +123,27 @@ QByteArray MethodCallScript(
script += method;
script += "(";
script += payload;
script += ", ";
script += token;
script += "); }";
return script;
}
QByteArray EventScript(const QString &event, const QByteArray &data) {
QByteArray EventScript(
const QString &event,
const QJsonObject &data,
const QString &shellToken) {
const auto eventValue = JsonValue(event);
const auto payload = JsonObject(data);
const auto token = JsonValue(shellToken);
auto script = QByteArray();
script += "if (window.TelegramDesktopShell) {"
"window.TelegramDesktopShell.nativeEvent(";
script += JsonValue(event);
script += eventValue;
script += ", ";
script += (data.isEmpty() ? QByteArray("{}") : data);
script += payload;
script += ", ";
script += token;
script += "); }";
return script;
}
@@ -23,13 +23,15 @@ struct ResolvedColors {
#ifdef Q_OS_LINUX
[[nodiscard]] QByteArray InstallScript();
[[nodiscard]] QByteArray InstallScript(const QString &shellToken);
[[nodiscard]] QByteArray MethodCallScript(
const QByteArray &method,
const QJsonObject &data);
const QJsonObject &data,
const QString &shellToken);
[[nodiscard]] QByteArray EventScript(
const QString &event,
const QByteArray &data);
const QJsonObject &data,
const QString &shellToken);
[[nodiscard]] QJsonObject Metrics();
[[nodiscard]] QSize WindowSize(QSize contentSize);
[[nodiscard]] QJsonObject MenuPalette();
@@ -37,19 +39,21 @@ struct ResolvedColors {
#else // Q_OS_LINUX
[[nodiscard]] inline QByteArray InstallScript() {
[[nodiscard]] inline QByteArray InstallScript(const QString &) {
return {};
}
[[nodiscard]] inline QByteArray MethodCallScript(
const QByteArray &,
const QJsonObject &) {
const QJsonObject &,
const QString &) {
return {};
}
[[nodiscard]] inline QByteArray EventScript(
const QString &,
const QByteArray &) {
const QJsonObject &,
const QString &) {
return {};
}