GODT-1667: bridge-gui spawns bridge process. [skip-ci]

Other: renaming of bridge-gui.
WIP: locate bridge exe.
WIP: bridge process launch.
WIP: cleaner closure of bridge.
WIP: grpcClient connection retries.
WIP: clean exit when bridge process is killed.

Fixed issues from MR review. [skip-ci].

WIP: Fixed gRPC case in CMakelists.txt [skip-ci]

It caused issues on Debian.

WIP: update gRPC/protobuf and tweaked CMakeLists.txt. [skip-ci]

WIP: Fixed a bug where splash screen could not be dismissed. [skip-ci]
This commit is contained in:
Xavier Michelon
2022-07-11 11:09:30 +02:00
committed by Jakub
parent 7a633ee8c8
commit 72708d6e2c
155 changed files with 4734 additions and 4199 deletions

View File

@ -21,6 +21,8 @@
#include "QMLBackend.h"
#include "GRPC/GRPCClient.h"
#include "Log.h"
#include "BridgeMonitor.h"
#include "Exception.h"
//****************************************************************************************************************************************************
@ -43,3 +45,23 @@ AppController::AppController()
{
}
//****************************************************************************************************************************************************
/// \return The bridge worker, which can be null if the application was run in 'attach' mode (-a command-line switch).
//****************************************************************************************************************************************************
BridgeMonitor *AppController::bridgeMonitor() const
{
if (!bridgeOverseer_)
return nullptr;
// null bridgeOverseer is OK, it means we run in 'attached' mode (app attached to an already runnning instance of Bridge).
// but if bridgeOverseer is not null, its attached worker must be a valid BridgeMonitor instance.
auto *monitor = dynamic_cast<BridgeMonitor*>(bridgeOverseer_->worker());
if (!monitor)
throw Exception("Could not retrieve bridge monitor");
return monitor;
}

View File

@ -23,7 +23,8 @@
class QMLBackend;
class GRPCClient;
class Log;
class Overseer;
class BridgeMonitor;
//****************************************************************************************************************************************************
/// \brief App controller class.
@ -42,6 +43,8 @@ public: // member functions.
QMLBackend& backend() { return *backend_; } ///< Return a reference to the backend.
GRPCClient& grpc() { return *grpc_; } ///< Return a reference to the GRPC client.
Log& log() { return *log_; } ///< Return a reference to the log.
std::unique_ptr<Overseer>& bridgeOverseer() { return bridgeOverseer_; }; ///< Returns a reference the bridge overseer
BridgeMonitor* bridgeMonitor() const; ///< Return the bridge worker.
private: // member functions
AppController(); ///< Default constructor.
@ -50,6 +53,7 @@ private: // data members
std::unique_ptr<QMLBackend> backend_; ///< The backend.
std::unique_ptr<GRPCClient> grpc_; ///< The RPC client.
std::unique_ptr<Log> log_; ///< The log.
std::unique_ptr<Overseer> bridgeOverseer_; ///< The overseer for the bridge monitor worker.
};

View File

@ -0,0 +1,110 @@
// Copyright (c) 2022 Proton AG
//
// This file is part of Proton Mail Bridge.
//
// Proton Mail Bridge is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Proton Mail Bridge is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
#include "BridgeMonitor.h"
#include "Exception.h"
namespace
{
/// \brief The file extension for the bridge executable file.
#ifdef Q_OS_WIN32
QString const exeSuffix = ".exe";
#else
QString const exeSuffix;
#endif
QString const exeName = "bridge" + exeSuffix; ///< The bridge executable file name.
QString const devDir = "cmd/Desktop-Bridge"; ///< The folder typically containg the bridge executable in a developer's environment.
int const maxExeUpwardSeekingDepth = 5; ///< The maximum number of parent folder that will searched when trying to locate the bridge executable.
}
//****************************************************************************************************************************************************
/// \return The path of the bridge executable.
/// \return A null string if the executable could not be located.
//****************************************************************************************************************************************************
QString BridgeMonitor::locateBridgeExe()
{
QString const currentDir = QDir::current().absolutePath();
QString const exeDir = QCoreApplication::applicationDirPath();
QStringList dirs = {currentDir, exeDir};
for (int i = 0; i <= maxExeUpwardSeekingDepth; ++i)
{
dirs.append(currentDir + QString("../").repeated(i) + devDir);
dirs.append(exeDir + QString("../").repeated(i) + devDir);
}
for (QString const &dir: dirs)
{
QFileInfo const fileInfo = QDir(dir).absoluteFilePath(exeName);
if (fileInfo.exists() && fileInfo.isFile() && fileInfo.isExecutable())
return fileInfo.absoluteFilePath();
}
return QString();
}
//****************************************************************************************************************************************************
/// \param[in] exePath The path of the Bridge executable.
/// \param[in] parent The parent object of the worker.
//****************************************************************************************************************************************************
BridgeMonitor::BridgeMonitor(QString const &exePath, QObject *parent)
: Worker(parent)
, exePath_(exePath)
{
QFileInfo fileInfo(exePath);
if (!fileInfo.exists())
throw Exception("Could not locate Bridge executable.");
if ((!fileInfo.isFile()) || (!fileInfo.isExecutable()))
throw Exception("Invalid bridge executable");
}
//****************************************************************************************************************************************************
//
//****************************************************************************************************************************************************
void BridgeMonitor::run()
{
try
{
emit started();
QProcess p;
p.start(exePath_);
p.waitForStarted();
while (!p.waitForFinished(100))
{
// we discard output from bridge, it's logged to file on bridge side.
p.readAllStandardError();
p.readAllStandardOutput();
}
emit processExited(p.exitCode());
emit finished();
}
catch (Exception const &e)
{
emit error(e.qwhat());
}
}

View File

@ -0,0 +1,54 @@
// Copyright (c) 2022 Proton AG
//
// This file is part of Proton Mail Bridge.
//
// Proton Mail Bridge is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Proton Mail Bridge is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
#ifndef BRIDGE_GUI_BRIDGE_MONITOR_H
#define BRIDGE_GUI_BRIDGE_MONITOR_H
#include "Worker/Worker.h"
//**********************************************************************************************************************
/// \brief Bridge process launcher and monitor class.
//**********************************************************************************************************************
class BridgeMonitor: public Worker
{
Q_OBJECT
public: // static member functions
static QString locateBridgeExe(); ///< Try to find the bridge executable path.
public: // member functions.
BridgeMonitor(QString const& exePath, QObject *parent); ///< Default constructor.
BridgeMonitor(BridgeMonitor const&) = delete; ///< Disabled copy-constructor.
BridgeMonitor(BridgeMonitor&&) = delete; ///< Disabled assignment copy-constructor.
~BridgeMonitor() override = default; ///< Destructor.
BridgeMonitor& operator=(BridgeMonitor const&) = delete; ///< Disabled assignment operator.
BridgeMonitor& operator=(BridgeMonitor&&) = delete; ///< Disabled move assignment operator.
void run() override; ///< Run the worker.
signals:
void processExited(int code); ///< Slot for the exiting of the process
private: // data members
QString const exePath_; ///< The path to the bridge executable.
};
#endif // BRIDGE_GUI_BRIDGE_MONITOR_H

View File

@ -32,7 +32,7 @@ endif()
set(CMAKE_TOOLCHAIN_FILE "$ENV{VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake" CACHE STRING "toolchain")
project(bridge_qt6 LANGUAGES CXX)
project(bridge-gui LANGUAGES CXX)
if (APPLE) # On macOS, we have some Objective-C++ code in DockIcon to deal with ... the dock icon.
enable_language(OBJC OBJCXX)
endif()
@ -52,12 +52,12 @@ find_package(Protobuf CONFIG REQUIRED)
message(STATUS "Using protobuf ${Protobuf_VERSION}")
find_package(grpc CONFIG REQUIRED)
find_package(gRPC CONFIG REQUIRED)
message(STATUS "Using gRPC ${gRPC_VERSION}")
if (APPLE) # We need to link the Cocoa framework for the dock icon.
find_library(COCOA_LIBRARY Cocoa REQUIRED)
find_library(COCOA_LIBRARY Cocoa REQ UIRED)
endif()
@ -67,9 +67,20 @@ find_package(Qt5 COMPONENTS
Qml
QuickControls2
REQUIRED)
message(STATUS "Using Qt ${Qt5_VERSION}")
find_program(PROTOC_EXE protoc REQUIRED)
message(STATUS "protoc found ${PROTOC_EXE}")
message(STATUS "grpc_cpp_plugin ${grpc_cpp_plugin}")
find_program(PROTOC_EXE protoc)
find_program(GRPC_CPP_PLUGIN grpc_cpp_plugin)
if(GRPC_CPP_PLUGIN STREQUAL GRPC_CPP_PLUGIN-NOTFOUND)
message(FATAL_ERROR "grpc_cpp_plugin exe could not be found. Please add it to your path. it should be located in \${VCPKG_ROOT}/installed/arm64-osx/tools/grpc")
else()
message(STATUS "grpc_cpp_plugin found at ${GRPC_CPP_PLUGIN}")
endif()
set(PROTO_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../grpc")
set(PROTO_FILE "${PROTO_DIR}/bridge.proto")
set(GRPC_OUT_DIR "${CMAKE_CURRENT_SOURCE_DIR}/GRPC")
@ -102,10 +113,11 @@ add_custom_command(
COMMENT "Generating gPRC/Protobuf C++ code"
)
add_executable(bridge_qt6
add_executable(bridge-gui
Resources.qrc
${PROTO_CPP_FILE} ${PROTO_H_FILE} ${GRPC_CPP_FILE} ${GRPC_H_FILE}
AppController.cpp AppController.h
BridgeMonitor.cpp BridgeMonitor.h
EventStreamWorker.cpp EventStreamWorker.h
Exception.cpp Exception.h
Log.cpp Log.h
@ -121,9 +133,9 @@ add_executable(bridge_qt6
include_directories(${CMAKE_CURRENT_SOURCE_DIR})
target_precompile_headers(bridge_qt6 PRIVATE Pch.h)
target_precompile_headers(bridge-gui PRIVATE Pch.h)
target_link_libraries(bridge_qt6
target_link_libraries(bridge-gui
Qt5::Core
Qt5::Quick
Qt5::Qml
@ -133,5 +145,5 @@ target_link_libraries(bridge_qt6
)
if (APPLE)
target_link_libraries(bridge_qt6 ${COCOA_LIBRARY})
target_link_libraries(bridge-gui ${COCOA_LIBRARY})
endif()

View File

@ -21,6 +21,7 @@
#include "GRPCUtils.h"
#include "QMLBackend.h"
#include "Exception.h"
#include "AppController.h"
using namespace google::protobuf;
@ -56,6 +57,8 @@ M7SXYbNDiLF4LwPLsunoLsW133Ky7s99MA==
Empty empty; // re-used across client calls.
int const maxConnectionTimeSecs = 60; ///< Amount of time after which we consider connection attemps to the server have failed.
}
@ -78,15 +81,25 @@ bool GRPCClient::connectToServer(QString &outError)
if (!stub_)
throw Exception("Stub creation failed.");
if (!channel_->WaitForConnected(gpr_time_add(gpr_now(GPR_CLOCK_REALTIME),
gpr_time_from_seconds(10, GPR_TIMESPAN))))
throw Exception("Connection to the RPC server failed.");
QDateTime const giveUpTime = QDateTime::currentDateTime().addSecs(maxConnectionTimeSecs); // if we reach giveUpTime without connecting, we give up
int i = 0;
while (true)
{
app().log().debug(QString("Connection to gRPC server. attempt #%1").arg(++i));
if (channel_->WaitForConnected(gpr_time_add(gpr_now(GPR_CLOCK_REALTIME), gpr_time_from_seconds(5, GPR_TIMESPAN))))
break; // connection established.
if (QDateTime::currentDateTime() > giveUpTime)
throw Exception("Connection to the RPC server failed.");
}
if (channel_->GetState(true) != GRPC_CHANNEL_READY)
throw Exception("connection check failed.");
QMLBackend *backend = &app().backend();
QObject::connect(this, &GRPCClient::loginFreeUserError, backend, &QMLBackend::loginFreeUserError);
app().log().debug("Successfully connected to gRPC server.");
return true;
}
catch (Exception const &e)

View File

@ -50,6 +50,12 @@ void QMLBackend::init()
eventStreamOverseer_ = std::make_unique<Overseer>(new EventStreamReader(nullptr), nullptr);
eventStreamOverseer_->startWorker(true);
// Grab from bridge the value that will not change during the execution of this app (or that will only change locally
logGRPCCallStatus(app().grpc().showSplashScreen(showSplashScreen_), "showSplashScreen");
logGRPCCallStatus(app().grpc().goos(goos_), "goos");
logGRPCCallStatus(app().grpc().logsPath(logsPath_), "logsPath");
logGRPCCallStatus(app().grpc().licensePath(licensePath_), "licensePath");
this->retrieveUserList();
}

View File

@ -51,7 +51,7 @@ public: // member functions.
public: // Qt/QML properties. Note that the NOTIFY-er signal is required even for read-only properties (QML warning otherwise)
Q_PROPERTY(bool showOnStartup READ showOnStartup NOTIFY showOnStartupChanged) // _ bool `property:showOnStartup`
Q_PROPERTY(bool showSplashScreen READ showSplashScreen NOTIFY showSplashScreenChanged) // _ bool `property:showSplashScreen`
Q_PROPERTY(bool showSplashScreen READ showSplashScreen WRITE setShowSplashScreen NOTIFY showSplashScreenChanged) // _ bool `property:showSplashScreen`
Q_PROPERTY(QString goos READ goos NOTIFY goosChanged) // _ string `property:"goos"`
Q_PROPERTY(QUrl logsPath READ logsPath NOTIFY logsPathChanged) // _ core.QUrl `property:"logsPath"`
Q_PROPERTY(QUrl licensePath READ licensePath NOTIFY licensePathChanged) // _ core.QUrl `property:"licensePath"`
@ -79,10 +79,11 @@ public: // Qt/QML properties. Note that the NOTIFY-er signal is required even fo
// Qt Property system setters & getters.
bool showOnStartup() const { bool v = false; logGRPCCallStatus(app().grpc().showOnStartup(v), "showOnStartup"); return v; };
bool showSplashScreen() { bool show = false; logGRPCCallStatus(app().grpc().showSplashScreen(show), "showSplashScreen"); return show; }
QString goos() { QString goos; logGRPCCallStatus(app().grpc().goos(goos), "goos"); return goos; }
QUrl logsPath() const { QUrl path; logGRPCCallStatus(app().grpc().logsPath(path), "logsPath"); return path;}
QUrl licensePath() const { QUrl path; logGRPCCallStatus(app().grpc().licensePath(path), "licensePath"); return path; }
bool showSplashScreen() const { return showSplashScreen_; };
void setShowSplashScreen(bool show) { if (show != showSplashScreen_) { showSplashScreen_ = show; emit showSplashScreenChanged(show); } }
QString goos() { return goos_; }
QUrl logsPath() const { return logsPath_; }
QUrl licensePath() const { return licensePath_; }
QUrl releaseNotesLink() const { return releaseNotesLink_; }
void setReleaseNotesLink(QUrl const& url) { if (url != releaseNotesLink_) { releaseNotesLink_ = url; emit releaseNotesLinkChanged(url); } }
QUrl dependencyLicensesLink() const { QUrl link; logGRPCCallStatus(app().grpc().dependencyLicensesLink(link), "dependencyLicensesLink"); return link; }
@ -212,6 +213,10 @@ private: // member functions
private: // data members
UserList* users_ { nullptr }; ///< The user list. Owned by backend.
std::unique_ptr<Overseer> eventStreamOverseer_; ///< The event stream overseer.
bool showSplashScreen_ { false }; ///< The cached version of show splash screen. Retrieved on startup from bridge, and potentially modified locally.
QString goos_; ///< The cached version of the GOOS variable.
QUrl logsPath_; ///< The logs path. Retrieved from bridge on startup.
QUrl licensePath_; ///< The license path. Retrieved from bridge on startup.
QUrl releaseNotesLink_; /// Release notes is not stored in the backend, it's pushed by the update check so we keep a local copy of it. \todo GODT-1670 Check this is implemented.
QUrl landingPageLink_; /// Landing page link is not stored in the backend, it's pushed by the update check so we keep a local copy of it. \todo GODT-1670 Check this is implemented.

View File

@ -56,13 +56,15 @@ void Overseer::startWorker(bool autorelease) const
worker_->moveToThread(thread_);
connect(thread_, &QThread::started, worker_, &Worker::run);
connect(worker_, &Worker::finished, thread_, &QThread::quit);
connect(worker_, &Worker::error, thread_, &QThread::quit);
connect(worker_, &Worker::finished, [&]() {thread_->quit(); }); // for unkwown reason, connect to the QThread::quit slot does not work...
connect(worker_, &Worker::error, [&]() { thread_->quit(); });
if (autorelease)
{
connect(worker_, &Worker::error, this, &Overseer::release);
connect(worker_, &Worker::finished, this, &Overseer::release);
}
thread_->start();
}
@ -92,7 +94,7 @@ void Overseer::release()
//****************************************************************************************************************************************************
/// \return true iff the worker is finished, release
/// \return true iff the worker is finished.
//****************************************************************************************************************************************************
bool Overseer::isFinished() const
{
@ -101,3 +103,12 @@ bool Overseer::isFinished() const
return worker_->thread()->isFinished();
}
//****************************************************************************************************************************************************
/// \return The worker.
//****************************************************************************************************************************************************
Worker *Overseer::worker() const
{
return worker_;
}

View File

@ -37,6 +37,7 @@ public: // member functions.
Overseer& operator=(Overseer const&) = delete; ///< Disabled assignment operator.
Overseer& operator=(Overseer&&) = delete; ///< Disabled move assignment operator.
bool isFinished() const; ///< Check if the worker is finished.
Worker *worker() const; ///< Return worker.
public slots:
void startWorker(bool autorelease) const; ///< Run the worker.

View File

@ -20,7 +20,7 @@
#include "Exception.h"
#include "QMLBackend.h"
#include "Log.h"
#include "EventStreamWorker.h"
#include "BridgeMonitor.h"
//****************************************************************************************************************************************************
@ -51,18 +51,19 @@ std::shared_ptr<QGuiApplication> initQtApplication(int argc, char *argv[])
//****************************************************************************************************************************************************
//
/// \return A reference to the log.
//****************************************************************************************************************************************************
void initLog()
Log &initLog()
{
Log &log = app().log();
log.setEchoInConsole(true);
log.setLevel(Log::Level::Debug);
return log;
}
//****************************************************************************************************************************************************
/// \param[in] engine The QML engine.
/// \param[in] engine The QML component.
//****************************************************************************************************************************************************
QQmlComponent *createRootQmlComponent(QQmlApplicationEngine &engine)
{
@ -88,6 +89,72 @@ QQmlComponent *createRootQmlComponent(QQmlApplicationEngine &engine)
}
//****************************************************************************************************************************************************
/// \param[in] exePath The path of the Bridge executable. If empty, the function will try to locate the bridge application.
//****************************************************************************************************************************************************
void launchBridge(QString const &exePath)
{
UPOverseer& overseer = app().bridgeOverseer();
overseer.reset();
QString bridgeExePath = exePath;
if (exePath.isEmpty())
bridgeExePath = BridgeMonitor::locateBridgeExe();
if (bridgeExePath.isEmpty())
throw Exception("Could not locate the bridge executable path");
else
app().log().debug(QString("Bridge executable path: %1").arg(QDir::toNativeSeparators(bridgeExePath)));
overseer = std::make_unique<Overseer>(new BridgeMonitor(bridgeExePath, nullptr), nullptr);
overseer->startWorker(true);
}
//****************************************************************************************************************************************************
/// \param[in] argc The number of command-line arguments.
/// \param[in] argv The list of command line arguments.
//****************************************************************************************************************************************************
void parseArguments(int argc, char **argv, bool &outAttach, QString &outExePath)
{
// for unknown reasons, on Windows QCoreApplication::arguments() frequently returns an empty list, which is incorrect, so we rebuild the argument
// list from the original argc and argv values.
QStringList args;
for (int i = 0; i < argc; i++)
args.append(QString::fromLocal8Bit(argv[i]));
// We do not want to 'advertise' the following switches, so we do not offer a '-h/--help' option.
// we have not yet connected to Bridge, we do not know the application version number, so we do not offer a -v/--version switch.
QCommandLineParser parser;
parser.setApplicationDescription("Proton Mail Bridge");
QCommandLineOption attachOption(QStringList() << "attach" << "a", "attach to an existing bridge process");
parser.addOption(attachOption);
QCommandLineOption exePathOption(QStringList() << "bridge-exe-path" << "b", "bridge executable path", "path", QString());
parser.addOption(exePathOption);
parser.process(args);
outAttach = parser.isSet(attachOption);
outExePath = parser.value(exePathOption);
}
//****************************************************************************************************************************************************
//
//****************************************************************************************************************************************************
void closeBridgeApp()
{
UPOverseer& overseer = app().bridgeOverseer();
if (!overseer) // The app was ran in 'attach' mode and attached to an existing instance of Bridge. No need to close.
return;
app().grpc().quit(); // this will cause the grpc service and the bridge app to close.
while (!overseer->isFinished())
{
QThread::msleep(20);
}
}
//****************************************************************************************************************************************************
/// \param[in] argc The number of command-line arguments.
/// \param[in] argv The list of command-line arguments.
@ -95,12 +162,18 @@ QQmlComponent *createRootQmlComponent(QQmlApplicationEngine &engine)
//****************************************************************************************************************************************************
int main(int argc, char *argv[])
{
std::shared_ptr<QGuiApplication> guiApp = initQtApplication(argc, argv);
try
{
std::shared_ptr<QGuiApplication> guiApp = initQtApplication(argc, argv);
initLog();
/// \todo GODT-1667 Locate & Launch go backend (and wait for it).
bool attach = false;
QString exePath;
parseArguments(argc, argv, attach, exePath);
Log &log = initLog();
if (!attach)
launchBridge(exePath);
app().backend().init();
@ -113,14 +186,21 @@ int main(int argc, char *argv[])
rootObject->setProperty("backend", QVariant::fromValue(&app().backend()));
rootComponent->completeCreate();
BridgeMonitor *bridgeMonitor = app().bridgeMonitor();
bool bridgeExited = false;
if (bridgeMonitor)
QObject::connect(bridgeMonitor, &BridgeMonitor::processExited, [&](int returnCode) {
// GODT-1671 We need to find a 'safe' way to check if brige crashed and restart instead of just quitting. Is returnCode enough?
bridgeExited = true;
qGuiApp->exit(returnCode);
});
int result = QGuiApplication::exec();
app().log().info(QString("Exiting app with return code %1").arg(result));
app().grpc().stopEventStream();
app().backend().clearUserList();
/// \todo GODT-1667 shutdown go backend.
app().backend().clearUserList(); // required for proper exit. We may want to investigate why at some point.
if (!bridgeExited)
closeBridgeApp();
return result;
}

View File

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

Before

Width:  |  Height:  |  Size: 1013 B

After

Width:  |  Height:  |  Size: 1013 B

View File

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

Before

Width:  |  Height:  |  Size: 304 B

After

Width:  |  Height:  |  Size: 304 B

View File

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

Before

Width:  |  Height:  |  Size: 230 B

After

Width:  |  Height:  |  Size: 230 B

View File

Before

Width:  |  Height:  |  Size: 283 B

After

Width:  |  Height:  |  Size: 283 B

View File

Before

Width:  |  Height:  |  Size: 284 B

After

Width:  |  Height:  |  Size: 284 B

View File

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Some files were not shown because too many files have changed in this diff Show More