/* This file is part of Telegram Desktop, the official desktop application for the Telegram messaging service. For license and copyright information please follow this link: https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL */ #include "iv/iv_controller.h" #include "base/platform/base_platform_info.h" #include "base/invoke_queued.h" #include "base/qt_signal_producer.h" #include "base/qthelp_url.h" #include "iv/iv_data.h" #include "lang/lang_keys.h" #include "ui/platform/ui_platform_window_title.h" #include "ui/widgets/buttons.h" #include "ui/widgets/labels.h" #include "ui/widgets/rp_window.h" #include "ui/widgets/popup_menu.h" #include "ui/wrap/fade_wrap.h" #include "ui/basic_click_handlers.h" #include "ui/painter.h" #include "webview/webview_data_stream_memory.h" #include "webview/webview_embed.h" #include "webview/webview_interface.h" #include "styles/palette.h" #include "styles/style_iv.h" #include "styles/style_menu_icons.h" #include "styles/style_widgets.h" #include "styles/style_window.h" #include #include #include #include #include #include #include #include #include "base/call_delayed.h" namespace Iv { namespace { [[nodiscard]] QByteArray ComputeStyles() { static const auto map = base::flat_map{ { "shadow-fg", &st::shadowFg }, { "scroll-bg", &st::scrollBg }, { "scroll-bg-over", &st::scrollBgOver }, { "scroll-bar-bg", &st::scrollBarBg }, { "scroll-bar-bg-over", &st::scrollBarBgOver }, { "window-bg", &st::windowBg }, { "window-bg-over", &st::windowBgOver }, { "window-bg-ripple", &st::windowBgRipple }, { "window-bg-active", &st::windowBgActive }, { "window-fg", &st::windowFg }, { "window-sub-text-fg", &st::windowSubTextFg }, { "window-active-text-fg", &st::windowActiveTextFg }, { "window-shadow-fg", &st::windowShadowFg }, { "box-divider-bg", &st::boxDividerBg }, { "box-divider-fg", &st::boxDividerFg }, { "light-button-fg", &st::lightButtonFg }, { "light-button-bg-over", &st::lightButtonBgOver }, { "menu-icon-fg", &st::menuIconFg }, { "menu-icon-fg-over", &st::menuIconFgOver }, { "menu-bg", &st::menuBg }, { "menu-bg-over", &st::menuBgOver }, { "history-to-down-fg", &st::historyToDownFg }, { "history-to-down-fg-over", &st::historyToDownFgOver }, { "history-to-down-bg", &st::historyToDownBg }, { "history-to-down-bg-over", &st::historyToDownBgOver }, { "history-to-down-bg-ripple", &st::historyToDownBgRipple }, { "history-to-down-shadow", &st::historyToDownShadow }, { "toast-bg", &st::toastBg }, { "toast-fg", &st::toastFg }, }; static const auto phrases = base::flat_map>{ { "iv-join-channel", tr::lng_iv_join_channel }, }; static const auto serialize = [](const style::color *color) { const auto qt = (*color)->c; if (qt.alpha() == 255) { return '#' + QByteArray::number(qt.red(), 16).right(2) + QByteArray::number(qt.green(), 16).right(2) + QByteArray::number(qt.blue(), 16).right(2); } return "rgba(" + QByteArray::number(qt.red()) + "," + QByteArray::number(qt.green()) + "," + QByteArray::number(qt.blue()) + "," + QByteArray::number(qt.alpha() / 255.) + ")"; }; static const auto escape = [](tr::phrase<> phrase) { const auto text = phrase(tr::now); auto result = QByteArray(); for (auto i = 0; i != text.size(); ++i) { uint ucs4 = text[i].unicode(); if (QChar::isHighSurrogate(ucs4) && i + 1 != text.size()) { ushort low = text[i + 1].unicode(); if (QChar::isLowSurrogate(low)) { ucs4 = QChar::surrogateToUcs4(ucs4, low); ++i; } } if (ucs4 == '\'' || ucs4 == '\"' || ucs4 == '\\') { result.append('\\').append(char(ucs4)); } else if (ucs4 < 32 || ucs4 > 127) { result.append('\\' + QByteArray::number(ucs4, 16) + ' '); } else { result.append(char(ucs4)); } } return result; }; auto result = QByteArray(); for (const auto &[name, phrase] : phrases) { result += "--td-lng-" + name + ":'" + escape(phrase) + "'; "; } for (const auto &[name, color] : map) { result += "--td-" + name + ':' + serialize(color) + ';'; } return result; } [[nodiscard]] QByteArray EscapeForAttribute(QByteArray value) { return value .replace('&', "&") .replace('"', """) .replace('\'', "'") .replace('<', "<") .replace('>', ">"); } [[nodiscard]] QByteArray EscapeForScriptString(QByteArray value) { return value .replace('\\', "\\\\") .replace('"', "\\\"") .replace('\'', "\\\'"); } [[nodiscard]] QByteArray WrapPage(const Prepared &page) { #ifdef Q_OS_MAC const auto classAttribute = ""_q; #else // Q_OS_MAC const auto classAttribute = " class=\"custom_scroll\""_q; #endif // Q_OS_MAC const auto js = QByteArray() + (page.hasCode ? "IV.initPreBlocks();" : "") + (page.hasEmbeds ? "IV.initEmbedBlocks();" : "") + "IV.init();" + page.script; const auto contentAttributes = page.rtl ? " dir=\"rtl\" class=\"rtl\""_q : QByteArray(); return R"(
"_q + page.content + R"(
)"_q; } [[nodiscard]] QByteArray ReadResource(const QString &name) { auto file = QFile(u":/iv/"_q + name); return file.open(QIODevice::ReadOnly) ? file.readAll() : QByteArray(); } } // namespace Controller::Controller( not_null delegate, Fn showShareBox) : _delegate(delegate) , _updateStyles([=] { const auto str = EscapeForScriptString(ComputeStyles()); if (_webview) { _webview->eval("IV.updateStyles('" + str + "');"); } }) , _showShareBox(std::move(showShareBox)) { createWindow(); } Controller::~Controller() { destroyShareMenu(); if (_window) { _window->hide(); } _ready = false; _webview = nullptr; _back.destroy(); _menu = nullptr; _menuToggle.destroy(); _subtitle = nullptr; _subtitleWrap = nullptr; _window = nullptr; } void Controller::updateTitleGeometry(int newWidth) const { _subtitleWrap->setGeometry( 0, st::windowTitleHeight, newWidth, st::ivSubtitleHeight); _subtitleWrap->paintRequest() | rpl::start_with_next([=](QRect clip) { QPainter(_subtitleWrap.get()).fillRect(clip, st::windowBg); }, _subtitleWrap->lifetime()); const auto progress = _subtitleLeft.value(_back->toggled() ? 1. : 0.); const auto left = anim::interpolate( st::ivSubtitleLeft, _back->width() + st::ivSubtitleSkip, progress); _subtitle->resizeToWidth(newWidth - left - _menuToggle->width()); _subtitle->moveToLeft(left, st::ivSubtitleTop); _back->moveToLeft(0, 0); _menuToggle->moveToRight(0, 0); } void Controller::initControls() { _subtitleWrap = std::make_unique(_window.get()); _subtitleText = _index.value() | rpl::filter( rpl::mappers::_1 >= 0 ) | rpl::map([=](int index) { return _pages[index].name; }); _subtitle = std::make_unique( _subtitleWrap.get(), _subtitleText.value(), st::ivSubtitle); _subtitleText.value( ) | rpl::start_with_next([=](const QString &subtitle) { const auto prefix = tr::lng_iv_window_title(tr::now); _window->setWindowTitle(prefix + ' ' + QChar(0x2014) + ' ' + subtitle); }, _subtitle->lifetime()); _subtitle->setAttribute(Qt::WA_TransparentForMouseEvents); _menuToggle.create(_subtitleWrap.get(), st::ivMenuToggle); _menuToggle->setClickedCallback([=] { showMenu(); }); _back.create( _subtitleWrap.get(), object_ptr(_subtitleWrap.get(), st::ivBack)); _back->entity()->setClickedCallback([=] { if (_webview) { _webview->eval("IV.back();"); } else { _back->hide(anim::type::normal); } }); _back->toggledValue( ) | rpl::start_with_next([=](bool toggled) { _subtitleLeft.start( [=] { updateTitleGeometry(_window->width()); }, toggled ? 0. : 1., toggled ? 1. : 0., st::fadeWrapDuration); }, _back->lifetime()); _back->hide(anim::type::instant); _subtitleLeft.stop(); } void Controller::show( const QString &dataPath, Prepared page, base::flat_map> inChannelValues) { page.script = fillInChannelValuesScript(std::move(inChannelValues)); InvokeQueued(_container, [=, page = std::move(page)]() mutable { showInWindow(dataPath, std::move(page)); }); } void Controller::update(Prepared page) { const auto url = page.url; auto i = _indices.find(url); if (i == end(_indices)) { return; } const auto index = i->second; _pages[index] = std::move(page); if (_ready) { _webview->eval(reloadScript(index)); } else if (!index) { _reloadInitialWhenReady = true; } } QByteArray Controller::fillInChannelValuesScript( base::flat_map> inChannelValues) { auto result = QByteArray(); for (auto &[id, in] : inChannelValues) { if (_inChannelSubscribed.emplace(id).second) { std::move(in) | rpl::start_with_next([=](bool in) { if (_ready) { _webview->eval(toggleInChannelScript(id, in)); } else { _inChannelChanged[id] = in; } }, _lifetime); } } for (const auto &[id, in] : base::take(_inChannelChanged)) { result += toggleInChannelScript(id, in); } return result; } QByteArray Controller::toggleInChannelScript( const QByteArray &id, bool in) const { const auto value = in ? "true" : "false"; return "IV.toggleChannelJoined('" + id + "', " + value + ");"; } void Controller::createWindow() { _window = std::make_unique(); const auto window = _window.get(); base::qt_signal_producer( window->window()->windowHandle(), &QWindow::activeChanged ) | rpl::filter([=] { return _webview && window->window()->windowHandle()->isActive(); }) | rpl::start_with_next([=] { setInnerFocus(); }, window->lifetime()); initControls(); window->widthValue() | rpl::start_with_next([=](int width) { updateTitleGeometry(width); }, _subtitle->lifetime()); window->setGeometry(_delegate->ivGeometry()); window->setMinimumSize({ st::windowMinWidth, st::windowMinHeight }); window->geometryValue( ) | rpl::distinct_until_changed( ) | rpl::skip(1) | rpl::start_with_next([=] { _delegate->ivSaveGeometry(window); }, window->lifetime()); _container = Ui::CreateChild(window->window()); rpl::combine( window->sizeValue(), _subtitleWrap->heightValue() ) | rpl::start_with_next([=](QSize size, int title) { _container->setGeometry(QRect(QPoint(), size).marginsRemoved( { 0, title + st::windowTitleHeight, 0, 0 })); }, _container->lifetime()); _container->paintRequest() | rpl::start_with_next([=](QRect clip) { QPainter(_container).fillRect(clip, st::windowBg); }, _container->lifetime()); _container->show(); window->show(); } void Controller::createWebview(const QString &dataPath) { Expects(!_webview); const auto window = _window.get(); _webview = std::make_unique( _container, Webview::WindowConfig{ .opaqueBg = st::windowBg->c, .userDataPath = dataPath, }); const auto raw = _webview.get(); window->lifetime().add([=] { _ready = false; _webview = nullptr; }); window->events( ) | rpl::start_with_next([=](not_null e) { if (e->type() == QEvent::Close) { close(); } else if (e->type() == QEvent::KeyPress) { const auto event = static_cast(e.get()); if (event->key() == Qt::Key_Escape) { escape(); } } }, window->lifetime()); raw->widget()->show(); _container->sizeValue( ) | rpl::start_with_next([=](QSize size) { raw->widget()->setGeometry(QRect(QPoint(), size)); }, _container->lifetime()); raw->setNavigationStartHandler([=](const QString &uri, bool newWindow) { return true; }); raw->setNavigationDoneHandler([=](bool success) { }); raw->setMessageHandler([=](const QJsonDocument &message) { crl::on_main(_window.get(), [=] { const auto object = message.object(); const auto event = object.value("event").toString(); if (event == u"keydown"_q) { const auto key = object.value("key").toString(); const auto modifier = object.value("modifier").toString(); processKey(key, modifier); } else if (event == u"mouseenter"_q) { window->overrideSystemButtonOver({}); } else if (event == u"mouseup"_q) { window->overrideSystemButtonDown({}); } else if (event == u"link_click"_q) { const auto url = object.value("url").toString(); const auto context = object.value("context").toString(); processLink(url, context); } else if (event == "menu_page_blocker_click") { if (_menu) { _menu->hideMenu(); } } else if (event == u"ready"_q) { _ready = true; auto script = QByteArray(); for (const auto &[id, in] : base::take(_inChannelChanged)) { script += toggleInChannelScript(id, in); } if (_navigateToIndexWhenReady >= 0) { script += navigateScript( std::exchange(_navigateToIndexWhenReady, -1), base::take(_navigateToHashWhenReady)); } if (base::take(_reloadInitialWhenReady)) { script += reloadScript(0); } if (_menu) { script += "IV.menuShown(true);"; } if (!script.isEmpty()) { _webview->eval(script); } } else if (event == u"location_change"_q) { _index = object.value("index").toInt(); _hash = object.value("hash").toString(); _back->toggle( (object.value("position").toInt() > 0), anim::type::normal); } }); }); raw->setDataRequestHandler([=](Webview::DataRequest request) { const auto pos = request.id.find('#'); if (pos != request.id.npos) { request.id = request.id.substr(0, pos); } if (!request.id.starts_with("iv/")) { _dataRequests.fire(std::move(request)); return Webview::DataResult::Pending; } const auto finishWith = [&](QByteArray data, std::string mime) { request.done({ .stream = std::make_unique( std::move(data), std::move(mime)), }); return Webview::DataResult::Done; }; const auto id = std::string_view(request.id).substr(3); if (id.starts_with("page") && id.ends_with(".html")) { if (!_subscribedToColors) { _subscribedToColors = true; rpl::merge( Lang::Updated(), style::PaletteChanged() ) | rpl::start_with_next([=] { _updateStyles.call(); }, _webview->lifetime()); } auto index = 0; const auto result = std::from_chars( id.data() + 4, id.data() + id.size() - 5, index); if (result.ec != std::errc() || index < 0 || index >= _pages.size()) { return Webview::DataResult::Failed; } return finishWith(WrapPage(_pages[index]), "text/html; charset=utf-8"); } else if (id.starts_with("page") && id.ends_with(".json")) { auto index = 0; const auto result = std::from_chars( id.data() + 4, id.data() + id.size() - 5, index); if (result.ec != std::errc() || index < 0 || index >= _pages.size()) { return Webview::DataResult::Failed; } auto &page = _pages[index]; return finishWith(QJsonDocument(QJsonObject{ { "html", QJsonValue(QString::fromUtf8(page.content)) }, { "js", QJsonValue(QString::fromUtf8(page.script)) }, }).toJson(QJsonDocument::Compact), "application/json"); } const auto css = id.ends_with(".css"); const auto js = !css && id.ends_with(".js"); if (!css && !js) { return Webview::DataResult::Failed; } const auto qstring = QString::fromUtf8(id.data(), id.size()); const auto pattern = u"^[a-zA-Z\\.\\-_0-9]+$"_q; if (QRegularExpression(pattern).match(qstring).hasMatch()) { const auto bytes = ReadResource(qstring); if (!bytes.isEmpty()) { const auto mime = css ? "text/css" : "text/javascript"; const auto full = (qstring == u"page.js"_q) ? (ReadResource("morphdom.js") + bytes) : bytes; return finishWith(full, mime); } } return Webview::DataResult::Failed; }); raw->init(R"()"); } void Controller::showInWindow(const QString &dataPath, Prepared page) { Expects(_container != nullptr); const auto url = page.url; _hash = page.hash; auto i = _indices.find(url); if (i == end(_indices)) { _pages.push_back(std::move(page)); i = _indices.emplace(url, int(_pages.size() - 1)).first; } const auto index = i->second; _index = index; if (!_webview) { createWebview(dataPath); if (_webview && _webview->widget()) { auto id = u"iv/page%1.html"_q.arg(index); if (!_hash.isEmpty()) { id += '#' + _hash; } _webview->navigateToData(id); activate(); } else { _events.fire({ Event::Type::Close }); } } else if (_ready) { _webview->eval(navigateScript(index, _hash)); activate(); } else { _navigateToIndexWhenReady = index; _navigateToHashWhenReady = _hash; activate(); } } void Controller::activate() { if (_window->isMinimized()) { _window->showNormal(); } else if (_window->isHidden()) { _window->show(); } _window->raise(); _window->activateWindow(); _window->setFocus(); setInnerFocus(); } void Controller::setInnerFocus() { if (const auto onstack = _shareFocus) { onstack(); } else if (_webview) { _webview->focus(); } } QByteArray Controller::navigateScript(int index, const QString &hash) { return "IV.navigateTo(" + QByteArray::number(index) + ", '" + EscapeForScriptString(qthelp::url_decode(hash).toUtf8()) + "');"; } QByteArray Controller::reloadScript(int index) { return "IV.reloadPage(" + QByteArray::number(index) + ");"; } void Controller::processKey(const QString &key, const QString &modifier) { const auto ctrl = Platform::IsMac() ? u"cmd"_q : u"ctrl"_q; if (key == u"escape"_q) { escape(); } else if (key == u"w"_q && modifier == ctrl) { close(); } else if (key == u"m"_q && modifier == ctrl) { minimize(); } else if (key == u"q"_q && modifier == ctrl) { quit(); } } void Controller::processLink(const QString &url, const QString &context) { const auto channelPrefix = u"channel"_q; const auto joinPrefix = u"join_link"_q; const auto webpagePrefix = u"webpage"_q; const auto viewerPrefix = u"viewer"_q; if (context.startsWith(channelPrefix)) { _events.fire({ .type = Event::Type::OpenChannel, .context = context.mid(channelPrefix.size()), }); } else if (context.startsWith(joinPrefix)) { _events.fire({ .type = Event::Type::JoinChannel, .context = context.mid(joinPrefix.size()), }); } else if (context.startsWith(webpagePrefix)) { _events.fire({ .type = Event::Type::OpenPage, .url = url, .context = context.mid(webpagePrefix.size()), }); } else if (context.startsWith(viewerPrefix)) { _events.fire({ .type = Event::Type::OpenMedia, .url = url, .context = context.mid(viewerPrefix.size()), }); } else if (context.isEmpty()) { _events.fire({ .type = Event::Type::OpenLink, .url = url }); } } bool Controller::active() const { return _window && _window->isActiveWindow(); } void Controller::showJoinedTooltip() { if (_webview && _ready) { _webview->eval("IV.showTooltip('" + EscapeForScriptString( tr::lng_action_you_joined(tr::now).toUtf8()) + "');"); } } void Controller::minimize() { if (_window) { _window->setWindowState(_window->windowState() | Qt::WindowMinimized); } } QString Controller::composeCurrentUrl() const { const auto index = _index.current(); Assert(index >= 0 && index < _pages.size()); return _pages[index].url + (_hash.isEmpty() ? u""_q : ('#' + _hash)); } void Controller::showMenu() { const auto index = _index.current(); if (_menu || index < 0 || index > _pages.size()) { return; } _menu = base::make_unique_q( _window.get(), st::popupMenuWithIcons); if (_webview && _ready) { _webview->eval("IV.menuShown(true);"); } _menu->setDestroyedCallback(crl::guard(_window.get(), [ this, weakButton = Ui::MakeWeak(_menuToggle.data()), menu = _menu.get()] { if (_menu == menu && weakButton) { weakButton->setForceRippled(false); } if (const auto widget = _webview ? _webview->widget() : nullptr) { InvokeQueued(widget, crl::guard(_window.get(), [=] { if (_webview && _ready) { _webview->eval("IV.menuShown(false);"); } })); } })); _menuToggle->setForceRippled(true); const auto url = composeCurrentUrl(); const auto openInBrowser = crl::guard(_window.get(), [=] { _events.fire({ .type = Event::Type::OpenLinkExternal, .url = url }); }); _menu->addAction( tr::lng_iv_open_in_browser(tr::now), openInBrowser, &st::menuIconIpAddress); _menu->addAction(tr::lng_iv_share(tr::now), [=] { showShareMenu(); }, &st::menuIconShare); _menu->setForcedOrigin(Ui::PanelAnimation::Origin::TopRight); _menu->popup(_window->body()->mapToGlobal( QPoint(_window->body()->width(), 0) + st::ivMenuPosition)); } void Controller::escape() { if (const auto onstack = _shareHide) { onstack(); } else { close(); } } void Controller::close() { _events.fire({ Event::Type::Close }); } void Controller::quit() { _events.fire({ Event::Type::Quit }); } rpl::lifetime &Controller::lifetime() { return _lifetime; } void Controller::destroyShareMenu() { _shareHide = nullptr; if (_shareFocus) { _shareFocus = nullptr; setInnerFocus(); } if (_shareWrap) { if (_shareContainer) { _shareWrap->windowHandle()->setParent(nullptr); } _shareWrap = nullptr; _shareContainer = nullptr; } if (_shareHidesContent) { _shareHidesContent = false; if (const auto content = _webview ? _webview->widget() : nullptr) { content->show(); } } } void Controller::showShareMenu() { const auto index = _index.current(); if (_shareWrap || index < 0 || index > _pages.size()) { return; } _shareHidesContent = Platform::IsMac(); if (_shareHidesContent) { if (const auto content = _webview ? _webview->widget() : nullptr) { content->hide(); } } _shareWrap = std::make_unique(_shareHidesContent ? _window->window() : nullptr); const auto margins = QMargins(0, st::windowTitleHeight, 0, 0); if (!_shareHidesContent) { _shareWrap->setGeometry(_window->geometry().marginsRemoved(margins)); _shareWrap->setWindowFlag(Qt::FramelessWindowHint); _shareWrap->setAttribute(Qt::WA_TranslucentBackground); _shareWrap->setAttribute(Qt::WA_NoSystemBackground); _shareWrap->createWinId(); _shareContainer.reset(QWidget::createWindowContainer( _shareWrap->windowHandle(), _window.get(), Qt::FramelessWindowHint | Qt::WindowStaysOnTopHint)); } _window->sizeValue() | rpl::start_with_next([=](QSize size) { const auto widget = _shareHidesContent ? _shareWrap.get() : _shareContainer.get(); widget->setGeometry(QRect(QPoint(), size).marginsRemoved(margins)); }, _shareWrap->lifetime()); auto result = _showShareBox({ .parent = _shareWrap.get(), .url = composeCurrentUrl(), }); _shareFocus = result.focus; _shareHide = result.hide; std::move(result.destroyRequests) | rpl::start_with_next([=] { destroyShareMenu(); }, _shareWrap->lifetime()); Ui::ForceFullRepaintSync(_shareWrap.get()); if (_shareHidesContent) { _shareWrap->show(); } else { _shareContainer->show(); } activate(); } } // namespace Iv