fix: various fixes for MTProxy & Telemt (#2653)

* fix color & fix enabled

* fixed remove base secret

* fix mtproxy/telemt 'base secret'

* fixed button back

* fixed loader

* fixed reload loader

* fixed dd secret

* fixed qml

* fix: fixed header link in mtproxy/telemt page

---------

Co-authored-by: vkamn <vk@amnezia.org>
This commit is contained in:
yp
2026-05-28 06:46:26 +03:00
committed by GitHub
parent 6f119cd083
commit 0a659a2d74
9 changed files with 428 additions and 269 deletions
@@ -119,9 +119,14 @@ ErrorCode InstallController::setupContainer(const ServerCredentials &credentials
return e; return e;
qDebug().noquote() << "InstallController::setupContainer prepareHostWorker finished"; qDebug().noquote() << "InstallController::setupContainer prepareHostWorker finished";
amnezia::ScriptVars removeContainerVars =
amnezia::genBaseVars(credentials, container, QString(), QString());
if (!isUpdate) {
removeContainerVars.append({ { "$REMOVE_CONTAINER_DATA", QStringLiteral("1") } });
}
sshSession.runScript(credentials, sshSession.runScript(credentials,
sshSession.replaceVars(amnezia::scriptData(SharedScriptType::remove_container), sshSession.replaceVars(amnezia::scriptData(SharedScriptType::remove_container),
amnezia::genBaseVars(credentials, container, QString(), QString()))); removeContainerVars));
qDebug().noquote() << "InstallController::setupContainer removeContainer finished"; qDebug().noquote() << "InstallController::setupContainer removeContainer finished";
qDebug().noquote() << "buildContainerWorker start"; qDebug().noquote() << "buildContainerWorker start";
@@ -942,10 +947,12 @@ ErrorCode InstallController::removeContainer(const QString &serverId, DockerCont
return ErrorCode::InternalError; return ErrorCode::InternalError;
} }
SshSession sshSession(this); SshSession sshSession(this);
amnezia::ScriptVars removeContainerVars =
amnezia::genBaseVars(credentials, container, QString(), QString());
removeContainerVars.append({ { "$REMOVE_CONTAINER_DATA", QStringLiteral("1") } });
ErrorCode errorCode = sshSession.runScript( ErrorCode errorCode = sshSession.runScript(
credentials, credentials,
sshSession.replaceVars(amnezia::scriptData(SharedScriptType::remove_container), sshSession.replaceVars(amnezia::scriptData(SharedScriptType::remove_container), removeContainerVars));
amnezia::genBaseVars(credentials, container, QString(), QString())));
if (errorCode == ErrorCode::NoError) { if (errorCode == ErrorCode::NoError) {
QMap<DockerContainer, ContainerConfig> containers = adminConfig->containers; QMap<DockerContainer, ContainerConfig> containers = adminConfig->containers;
@@ -295,6 +295,8 @@ amnezia::ScriptVars amnezia::genMtProxyVars(const ContainerConfig &containerConf
vars.append({{"$MTPROXY_PORT", c.port.isEmpty() ? QString(protocols::mtProxy::defaultPort) : c.port}}); vars.append({{"$MTPROXY_PORT", c.port.isEmpty() ? QString(protocols::mtProxy::defaultPort) : c.port}});
vars.append({{"$MTPROXY_SECRET", c.secret}}); vars.append({{"$MTPROXY_SECRET", c.secret}});
vars.append({{"$MTPROXY_REGENERATE_SECRET",
c.secret.isEmpty() ? QStringLiteral("1") : QStringLiteral("0")}});
vars.append({{"$MTPROXY_TAG", c.tag}}); vars.append({{"$MTPROXY_TAG", c.tag}});
vars.append({{"$MTPROXY_TRANSPORT_MODE", vars.append({{"$MTPROXY_TRANSPORT_MODE",
c.transportMode.isEmpty() ? QString(protocols::mtProxy::transportModeStandard) c.transportMode.isEmpty() ? QString(protocols::mtProxy::transportModeStandard)
@@ -350,6 +352,8 @@ amnezia::ScriptVars amnezia::genTelemtVars(const ContainerConfig &containerConfi
vars.append({ { "$TELEMT_TOML_TLS", faketls ? QLatin1String("true") : QLatin1String("false") } }); vars.append({ { "$TELEMT_TOML_TLS", faketls ? QLatin1String("true") : QLatin1String("false") } });
vars.append({ { "$TELEMT_PORT", c.port.isEmpty() ? QString(protocols::telemt::defaultPort) : c.port } }); vars.append({ { "$TELEMT_PORT", c.port.isEmpty() ? QString(protocols::telemt::defaultPort) : c.port } });
vars.append({ { "$TELEMT_SECRET", c.secret } }); vars.append({ { "$TELEMT_SECRET", c.secret } });
vars.append({ { "$TELEMT_REGENERATE_SECRET",
c.secret.isEmpty() ? QStringLiteral("1") : QStringLiteral("0") } });
vars.append({ { "$TELEMT_TAG", c.tag } }); vars.append({ { "$TELEMT_TAG", c.tag } });
QString tlsDomain = c.tlsDomain; QString tlsDomain = c.tlsDomain;
if (tlsDomain.isEmpty()) { if (tlsDomain.isEmpty()) {
@@ -4,8 +4,10 @@
curl -s https://core.telegram.org/getProxySecret -o /data/proxy-secret curl -s https://core.telegram.org/getProxySecret -o /data/proxy-secret
curl -s https://core.telegram.org/getProxyConfig -o /data/proxy-multi.conf curl -s https://core.telegram.org/getProxyConfig -o /data/proxy-multi.conf
# Determine secret: env var -> saved file -> generate new # Determine secret: regenerate (fresh install) -> env var -> saved file -> generate new
if [ -n "$MTPROXY_SECRET" ]; then if [ "$MTPROXY_REGENERATE_SECRET" = "1" ]; then
SECRET=$(openssl rand -hex 16)
elif [ -n "$MTPROXY_SECRET" ]; then
SECRET="$MTPROXY_SECRET" SECRET="$MTPROXY_SECRET"
elif [ -f /data/secret ]; then elif [ -f /data/secret ]; then
SECRET=$(cat /data/secret) SECRET=$(cat /data/secret)
+2 -1
View File
@@ -1,3 +1,4 @@
sudo docker stop $CONTAINER_NAME;\ sudo docker stop $CONTAINER_NAME;\
sudo docker rm -fv $CONTAINER_NAME;\ sudo docker rm -fv $CONTAINER_NAME;\
sudo docker rmi $CONTAINER_NAME sudo docker rmi $CONTAINER_NAME;\
test "$REMOVE_CONTAINER_DATA" = "1" && sudo docker volume rm -f ${CONTAINER_NAME}-data 2>/dev/null || true
@@ -4,8 +4,10 @@
echo "[*] Amnezia Telemt: configure script start" echo "[*] Amnezia Telemt: configure script start"
mkdir -p /data/tlsfront mkdir -p /data/tlsfront
# Secret: substituted $TELEMT_SECRET -> saved file -> openssl (same rules as MTProxy configure) # Secret: regenerate (fresh install) -> env var -> saved file -> openssl
if [ -n "$TELEMT_SECRET" ]; then if [ "$TELEMT_REGENERATE_SECRET" = "1" ]; then
SECRET=$(openssl rand -hex 16)
elif [ -n "$TELEMT_SECRET" ]; then
SECRET="$TELEMT_SECRET" SECRET="$TELEMT_SECRET"
elif [ -f /data/secret ]; then elif [ -f /data/secret ]; then
SECRET=$(cat /data/secret) SECRET=$(cat /data/secret)
@@ -47,10 +47,10 @@ ListViewType {
PageController.goToPage(PageEnum.PageServiceDnsSettings) PageController.goToPage(PageEnum.PageServiceDnsSettings)
} else if (isMtProxy) { } else if (isMtProxy) {
MtProxyConfigModel.updateModel(config) MtProxyConfigModel.updateModel(config)
PageController.goToPage(PageEnum.PageServiceMtProxySettings) PageController.goToPage(PageEnum.PageServiceMtProxySettings, false)
} else if (isTelemt) { } else if (isTelemt) {
TelemtConfigModel.updateModel(config) TelemtConfigModel.updateModel(config)
PageController.goToPage(PageEnum.PageServiceTelemtSettings) PageController.goToPage(PageEnum.PageServiceTelemtSettings, false)
} else { } else {
InstallController.updateProtocols(ServersUiController.processedServerId, containerIndex) InstallController.updateProtocols(ServersUiController.processedServerId, containerIndex)
PageController.goToPage(PageEnum.PageSettingsServerProtocol) PageController.goToPage(PageEnum.PageSettingsServerProtocol)
@@ -12,6 +12,8 @@ Item {
property int headerTextMaximumLineCount: 2 property int headerTextMaximumLineCount: 2
property int headerTextElide: Qt.ElideRight property int headerTextElide: Qt.ElideRight
property string descriptionText property string descriptionText
property string descriptionLinkText
property string descriptionLinkUrl
property alias headerRow: headerRow property alias headerRow: headerRow
implicitWidth: content.implicitWidth implicitWidth: content.implicitWidth
@@ -43,5 +45,26 @@ Item {
color: AmneziaStyle.color.mutedGray color: AmneziaStyle.color.mutedGray
visible: root.descriptionText !== "" visible: root.descriptionText !== ""
} }
ParagraphTextType {
id: descriptionLink
Layout.topMargin: 16
Layout.fillWidth: true
text: root.descriptionLinkText !== "" && root.descriptionLinkUrl !== ""
? ("<a href=\"" + root.descriptionLinkUrl + "\" style=\"color: " + AmneziaStyle.color.goldenApricotString + ";\">" + root.descriptionLinkText + "</a>")
: ""
textFormat: Text.RichText
visible: root.descriptionLinkText !== ""
onLinkActivated: function(link) {
Qt.openUrlExternally(link)
}
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.NoButton
cursorShape: parent.hoveredLink ? Qt.PointingHandCursor : Qt.ArrowCursor
}
}
} }
} }
@@ -20,15 +20,10 @@ import "../Components"
PageType { PageType {
id: root id: root
Rectangle {
anchors.fill: parent
z: -1
color: AmneziaStyle.color.onyxBlack
}
property int containerStatus: 1 property int containerStatus: 1
property bool isUpdating: false property bool isUpdating: false
property bool isCheckingStatus: false property bool isCheckingStatus: false
property bool isFetchingSecret: false
property bool previousEnabled: true property bool previousEnabled: true
property int previousContainerStatus: 1 property int previousContainerStatus: 1
@@ -50,7 +45,7 @@ PageType {
onSavedTransportModeChanged: { onSavedTransportModeChanged: {
if (savedTransportMode === "faketls") { if (savedTransportMode === "faketls") {
root.syncedSecretTabIndex = 2 root.syncedSecretTabIndex = 1
} else if (savedTransportMode !== "") { } else if (savedTransportMode !== "") {
root.syncedSecretTabIndex = 0 root.syncedSecretTabIndex = 0
} }
@@ -68,9 +63,96 @@ PageType {
readonly property bool mtProxyNetworkBlocked: !NetworkReachabilityController.hasInternetAccess readonly property bool mtProxyNetworkBlocked: !NetworkReachabilityController.hasInternetAccess
readonly property bool navigationBlockedWhileBusy: isUpdating || diagLoading property bool remoteOperationBusy: false
readonly property bool operationInProgress: isCheckingStatus || isFetchingSecret || isUpdating || diagLoading
readonly property bool pageBusy: operationInProgress || remoteOperationBusy
readonly property bool navigationBlockedWhileBusy: pageBusy
property bool pageOpenHandled: false
property bool busyIndicatorShown: false
function syncPageBusyIndicator() {
if (!root.pageOpenHandled) {
return
}
var wantBusy = root.pageBusy
if (wantBusy === root.busyIndicatorShown) {
return
}
root.busyIndicatorShown = wantBusy
PageController.showBusyIndicator(wantBusy)
}
onPageBusyChanged: syncPageBusyIndicator()
function mtProxyDomainToHex(domain) {
var hex = ""
for (var i = 0; i < domain.length; i++) {
var code = domain.charCodeAt(i).toString(16)
hex += (code.length < 2 ? "0" : "") + code
}
return hex
}
function mtProxyClientSecret(baseHex32, mode, tlsDomain) {
if (baseHex32 === "") {
return ""
}
if (mode === "faketls") {
return "ee" + baseHex32 + mtProxyDomainToHex(tlsDomain)
}
return "dd" + baseHex32
}
function mtProxyClientSecretForTabIndex(baseHex32, tabIndex, tlsDomain, defaultTlsDomain) {
var domain = tlsDomain !== "" ? tlsDomain : defaultTlsDomain
if (tabIndex === 1) {
return mtProxyClientSecret(baseHex32, "faketls", domain)
}
return mtProxyClientSecret(baseHex32, "standard", domain)
}
property bool containerStatusRefreshCallPending: false
function mtProxyRequestContainerStatusRefresh() {
if (!NetworkReachabilityController.hasInternetAccess) {
isCheckingStatus = false
syncPageBusyIndicator()
return
}
isCheckingStatus = true
syncPageBusyIndicator()
InstallController.refreshContainerStatus(ServersUiController.processedServerId, ServersUiController.processedContainerIndex)
}
function mtProxyScheduleContainerStatusRefresh() {
if (containerStatusRefreshCallPending) {
return
}
containerStatusRefreshCallPending = true
Qt.callLater(function () {
containerStatusRefreshCallPending = false
root.mtProxyRequestContainerStatusRefresh()
})
}
function mtProxyOnPageShown() {
if (root.pageOpenHandled) {
return
}
root.pageOpenHandled = true
PageController.disableControls(navigationBlockedWhileBusy)
if (!NetworkReachabilityController.hasInternetAccess) {
isCheckingStatus = false
} else {
isCheckingStatus = true
}
syncPageBusyIndicator()
root.mtProxyScheduleContainerStatusRefresh()
}
// Hex values that exist in last loaded / last successfully saved config — show link panel only for these.
property var mtProxyPersistedAdditionalHex: [] property var mtProxyPersistedAdditionalHex: []
function mtProxyRefreshPersistedAdditionalSecrets() { function mtProxyRefreshPersistedAdditionalSecrets() {
@@ -92,11 +174,8 @@ PageType {
return false return false
} }
// Rejects garbage like "123123123123"; only dotted IPv4 shape (≤3 digits per octet, ≤4 octets).
readonly property var natIpv4InputFormat: /^(\d{1,3}\.){0,3}\d{0,3}$/ readonly property var natIpv4InputFormat: /^(\d{1,3}\.){0,3}\d{0,3}$/
// Defer SSH/updateContainer so QML control handlers return before nested event loops run;
// avoids "Object destroyed while one of its QML signal handlers is in progress".
function mtProxyScheduleUpdate(closePage) { function mtProxyScheduleUpdate(closePage) {
var cp = closePage === undefined ? false : closePage var cp = closePage === undefined ? false : closePage
Qt.callLater(function () { Qt.callLater(function () {
@@ -104,7 +183,6 @@ PageType {
}) })
} }
// Optional IPv4: show invalid while typing only when the string looks complete (four octets), so partial entry is not nagged.
function natIpv4FieldShowInvalidError(text) { function natIpv4FieldShowInvalidError(text) {
var t = text ? String(text).replace(/^\s+|\s+$/g, '') : "" var t = text ? String(text).replace(/^\s+|\s+$/g, '') : ""
if (t === "") if (t === "")
@@ -167,15 +245,9 @@ PageType {
root.mtProxyRefreshPersistedAdditionalSecrets() root.mtProxyRefreshPersistedAdditionalSecrets()
}) })
if (!NetworkReachabilityController.hasInternetAccess) { Qt.callLater(root.mtProxyOnPageShown)
isCheckingStatus = false
return
}
isCheckingStatus = true
InstallController.refreshContainerStatus(ServersUiController.processedServerId, ServersUiController.processedContainerIndex)
} }
// Block back navigation and Escape (via PageStart.isControlsDisabled) while SSH/update or diagnostics refresh runs.
onNavigationBlockedWhileBusyChanged: { onNavigationBlockedWhileBusyChanged: {
if (root.visible) { if (root.visible) {
PageController.disableControls(navigationBlockedWhileBusy) PageController.disableControls(navigationBlockedWhileBusy)
@@ -184,10 +256,16 @@ PageType {
onVisibleChanged: { onVisibleChanged: {
if (!visible) { if (!visible) {
root.pageOpenHandled = false
containerStatusRefreshCallPending = false
isCheckingStatus = false
isFetchingSecret = false
busyIndicatorShown = false
PageController.disableControls(false) PageController.disableControls(false)
PageController.showBusyIndicator(false)
diagLoading = false diagLoading = false
} else { } else {
PageController.disableControls(navigationBlockedWhileBusy) root.mtProxyOnPageShown()
} }
} }
@@ -199,8 +277,7 @@ PageType {
return return
} }
if (NetworkReachabilityController.hasInternetAccess) { if (NetworkReachabilityController.hasInternetAccess) {
isCheckingStatus = true root.mtProxyScheduleContainerStatusRefresh()
InstallController.refreshContainerStatus(ServersUiController.processedServerId, ServersUiController.processedContainerIndex)
} }
} }
} }
@@ -208,10 +285,15 @@ PageType {
Connections { Connections {
target: InstallController target: InstallController
function onServerIsBusy(busy) {
remoteOperationBusy = busy
}
function onUpdateContainerFinished(message, closePage) { function onUpdateContainerFinished(message, closePage) {
if (!root.visible) { if (!root.visible) {
isUpdating = false isUpdating = false
isCheckingStatus = false isCheckingStatus = false
isFetchingSecret = false
return return
} }
isUpdating = false isUpdating = false
@@ -227,9 +309,11 @@ PageType {
if (!root.visible) { if (!root.visible) {
isUpdating = false isUpdating = false
isCheckingStatus = false isCheckingStatus = false
isFetchingSecret = false
return return
} }
isUpdating = false isUpdating = false
isFetchingSecret = false
containerStatus = previousContainerStatus containerStatus = previousContainerStatus
MtProxyConfigModel.setEnabled(previousEnabled) MtProxyConfigModel.setEnabled(previousEnabled)
MtProxyConfigModel.setPort(previousPort) MtProxyConfigModel.setPort(previousPort)
@@ -254,6 +338,7 @@ PageType {
} }
if (enabled && pendingUpdateAfterEnable) { if (enabled && pendingUpdateAfterEnable) {
pendingUpdateAfterEnable = false pendingUpdateAfterEnable = false
isUpdating = true
root.mtProxyScheduleUpdate(false) root.mtProxyScheduleUpdate(false)
return return
} }
@@ -266,9 +351,9 @@ PageType {
function onContainerStatusRefreshed(status) { function onContainerStatusRefreshed(status) {
if (!root.visible) { if (!root.visible) {
isCheckingStatus = false isCheckingStatus = false
isFetchingSecret = false
return return
} }
isCheckingStatus = false
containerStatus = status containerStatus = status
root.savedTransportMode = MtProxyConfigModel.getTransportMode() root.savedTransportMode = MtProxyConfigModel.getTransportMode()
@@ -276,11 +361,18 @@ PageType {
root.savedPublicHost = MtProxyConfigModel.getPublicHost() root.savedPublicHost = MtProxyConfigModel.getPublicHost()
if (status === 1) { if (status === 1) {
MtProxyConfigModel.setEnabled(true) MtProxyConfigModel.setEnabled(true)
isFetchingSecret = true
isCheckingStatus = false
InstallController.fetchContainerSecret(ServersUiController.processedServerId, ServersUiController.processedContainerIndex) InstallController.fetchContainerSecret(ServersUiController.processedServerId, ServersUiController.processedContainerIndex)
} else if (status === 2) { } else {
isFetchingSecret = false
isCheckingStatus = false
if (status === 2) {
MtProxyConfigModel.setEnabled(false) MtProxyConfigModel.setEnabled(false)
} }
} }
syncPageBusyIndicator()
}
function onContainerDiagnosticsRefreshed(portReachable, upstreamReachable, clientsConnected, lastConfigRefresh, statsEndpoint) { function onContainerDiagnosticsRefreshed(portReachable, upstreamReachable, clientsConnected, lastConfigRefresh, statsEndpoint) {
if (!root.visible) { if (!root.visible) {
@@ -296,20 +388,35 @@ PageType {
function onContainerSecretFetched(secret) { function onContainerSecretFetched(secret) {
if (!root.visible) { if (!root.visible) {
isFetchingSecret = false
return return
} }
isFetchingSecret = false
syncPageBusyIndicator()
MtProxyConfigModel.validateAndSetSecret(secret) MtProxyConfigModel.validateAndSetSecret(secret)
} }
} }
Item {
id: contentLayer
anchors.fill: parent
enabled: !root.pageBusy
BackButtonType { BackButtonType {
id: backButton id: backButton
anchors.top: parent.top anchors.top: parent.top
anchors.left: parent.left anchors.left: parent.left
anchors.right: parent.right anchors.right: parent.right
anchors.topMargin: 20 + SettingsController.safeAreaTopMargin anchors.topMargin: 20 + PageController.safeAreaTopMargin
onFocusChanged: { onFocusChanged: {
if (this.activeFocus) connectionListView.positionViewAtBeginning() if (this.activeFocus) {
if (mainTabBar.currentIndex === 0) {
connectionListView.positionViewAtBeginning()
} else {
settingsListView.positionViewAtBeginning()
}
}
} }
} }
@@ -318,32 +425,38 @@ PageType {
anchors.top: backButton.bottom anchors.top: backButton.bottom
anchors.left: parent.left anchors.left: parent.left
anchors.right: parent.right anchors.right: parent.right
anchors.topMargin: 8
spacing: 0 spacing: 0
BaseHeaderType { BaseHeaderType {
Layout.fillWidth: true Layout.fillWidth: true
Layout.leftMargin: 16 Layout.leftMargin: 16
Layout.rightMargin: 16 Layout.rightMargin: 16
Layout.topMargin: 8 Layout.bottomMargin: 24
headerText: qsTr("MTProxy settings") headerText: qsTr("MTProxy settings")
descriptionLinkText: qsTr("Read more about this settings")
descriptionLinkUrl: "https://core.telegram.org/proxy"
} }
LabelWithButtonType { CaptionTextType {
Layout.fillWidth: true Layout.fillWidth: true
Layout.leftMargin: 0 Layout.leftMargin: 16
Layout.rightMargin: 16 Layout.rightMargin: 16
text: qsTr("Read more about this settings") Layout.topMargin: 8
textColor: AmneziaStyle.color.goldenApricot visible: root.mtProxyNetworkBlocked
clickedFunction: function () { text: qsTr("No internet connection. Connect to the internet to change MTProxy settings.")
Qt.openUrlExternally("https://core.telegram.org/proxy") color: AmneziaStyle.color.mutedGray
wrapMode: Text.WordWrap
font.pixelSize: 14
} }
} }
TabBar { TabBar {
id: mainTabBar id: mainTabBar
Layout.fillWidth: true anchors.top: pageHeader.bottom
Layout.topMargin: 4 anchors.left: parent.left
anchors.right: parent.right
width: parent.width
background: Rectangle { background: Rectangle {
color: AmneziaStyle.color.transparent color: AmneziaStyle.color.transparent
@@ -364,11 +477,10 @@ PageType {
isSelected: mainTabBar.currentIndex === 1 isSelected: mainTabBar.currentIndex === 1
} }
} }
}
StackLayout { StackLayout {
id: tabContent id: tabContent
anchors.top: pageHeader.bottom anchors.top: mainTabBar.bottom
anchors.bottom: parent.bottom anchors.bottom: parent.bottom
anchors.left: parent.left anchors.left: parent.left
anchors.right: parent.right anchors.right: parent.right
@@ -382,35 +494,11 @@ PageType {
width: connectionListView.width width: connectionListView.width
spacing: 0 spacing: 0
function domainToHex(domain) {
var hex = ""
for (var i = 0; i < domain.length; i++) {
var code = domain.charCodeAt(i).toString(16)
hex += (code.length < 2 ? "0" : "") + code
}
return hex
}
function secretForMode(mode) {
if (mode === "faketls") {
var domain = root.savedTlsDomain !== "" ? root.savedTlsDomain : MtProxyConfigModel.defaultTlsDomain()
return "ee" + secret + domainToHex(domain)
} else if (mode === "padded") {
return "dd" + secret
}
return secret
}
property int secretTabIndex: root.syncedSecretTabIndex property int secretTabIndex: root.syncedSecretTabIndex
function activeSecret() { function activeSecret() {
if (root.syncedSecretTabIndex === 0) { return root.mtProxyClientSecretForTabIndex(secret, root.syncedSecretTabIndex,
return secretForMode("standard") root.savedTlsDomain, MtProxyConfigModel.defaultTlsDomain())
}
if (root.syncedSecretTabIndex === 1) {
return secretForMode("padded")
}
return secretForMode("faketls")
} }
function effectiveSecret() { function effectiveSecret() {
@@ -754,33 +842,9 @@ PageType {
width: settingsListView.width width: settingsListView.width
spacing: 0 spacing: 0
function mtProxyDomainToHex(domain) {
var hex = ""
for (var i = 0; i < domain.length; i++) {
var code = domain.charCodeAt(i).toString(16)
hex += (code.length < 2 ? "0" : "") + code
}
return hex
}
function mtProxySecretForBaseHex(baseHex, mode) {
if (mode === "faketls") {
var domain = root.savedTlsDomain !== "" ? root.savedTlsDomain : MtProxyConfigModel.defaultTlsDomain()
return "ee" + baseHex + mtProxyDomainToHex(domain)
} else if (mode === "padded") {
return "dd" + baseHex
}
return baseHex
}
function mtProxyActiveSecretForBaseHex(baseHex) { function mtProxyActiveSecretForBaseHex(baseHex) {
if (root.syncedSecretTabIndex === 0) { return root.mtProxyClientSecretForTabIndex(baseHex, root.syncedSecretTabIndex,
return mtProxySecretForBaseHex(baseHex, "standard") root.savedTlsDomain, MtProxyConfigModel.defaultTlsDomain())
}
if (root.syncedSecretTabIndex === 1) {
return mtProxySecretForBaseHex(baseHex, "padded")
}
return mtProxySecretForBaseHex(baseHex, "faketls")
} }
function mtProxyEffectiveHostForLinks() { function mtProxyEffectiveHostForLinks() {
@@ -804,7 +868,7 @@ PageType {
Layout.bottomMargin: 16 Layout.bottomMargin: 16
text: qsTr("Enable MTProxy") text: qsTr("Enable MTProxy")
checked: isEnabled checked: isEnabled
enabled: !isCheckingStatus && containerStatus !== 0 && containerStatus !== 3 && !isUpdating enabled: containerStatus !== 0 && containerStatus !== 3 && !root.pageBusy
&& !root.mtProxyNetworkBlocked && !root.mtProxyNetworkBlocked
onToggled: function () { onToggled: function () {
if (checked !== isEnabled) { if (checked !== isEnabled) {
@@ -843,13 +907,14 @@ PageType {
CaptionTextType { CaptionTextType {
Layout.fillWidth: true Layout.fillWidth: true
text: secret !== "" ? secret : qsTr("Not generated") text: secret !== "" ? mtProxyActiveSecretForBaseHex(secret) : qsTr("Not generated")
color: secret !== "" ? AmneziaStyle.color.paleGray : AmneziaStyle.color.mutedGray color: secret !== "" ? AmneziaStyle.color.paleGray : AmneziaStyle.color.mutedGray
elide: Text.ElideMiddle wrapMode: Text.WrapAnywhere
font.pixelSize: 14 font.pixelSize: 14
} }
ImageButtonType { ImageButtonType {
Layout.alignment: Qt.AlignTop
implicitWidth: 36 implicitWidth: 36
implicitHeight: 36 implicitHeight: 36
hoverEnabled: true hoverEnabled: true
@@ -1098,6 +1163,7 @@ PageType {
clickedFunction: function () { clickedFunction: function () {
transportMode = (index === 0) ? "standard" : "faketls" transportMode = (index === 0) ? "standard" : "faketls"
MtProxyConfigModel.setTransportMode(transportMode) MtProxyConfigModel.setTransportMode(transportMode)
root.syncedSecretTabIndex = transportMode === "faketls" ? 1 : 0
transportModeDropDown.closeTriggered() transportModeDropDown.closeTriggered()
} }
} }
@@ -1286,6 +1352,9 @@ PageType {
imageColor: AmneziaStyle.color.vibrantRed imageColor: AmneziaStyle.color.vibrantRed
onClicked: { onClicked: {
MtProxyConfigModel.removeAdditionalSecret(index) MtProxyConfigModel.removeAdditionalSecret(index)
if (containerStatus === 1) {
root.mtProxyScheduleUpdate(false)
}
} }
} }
} }
@@ -1852,34 +1921,5 @@ PageType {
} }
} }
Rectangle {
anchors.fill: parent
visible: isCheckingStatus || isUpdating || root.mtProxyNetworkBlocked
color: AmneziaStyle.color.midnightBlack
opacity: 0.6
z: 1
MouseArea {
anchors.fill: parent
}
BusyIndicator {
anchors.centerIn: parent
visible: isCheckingStatus || isUpdating
running: isCheckingStatus || isUpdating
width: 48
height: 48
}
CaptionTextType {
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: 24
anchors.rightMargin: 24
visible: root.mtProxyNetworkBlocked && !isCheckingStatus && !isUpdating
horizontalAlignment: Text.AlignHCenter
text: qsTr("No internet connection. Connect to the internet to change MTProxy settings.")
color: AmneziaStyle.color.paleGray
wrapMode: Text.WordWrap
font.pixelSize: 14
}
} }
} }
@@ -18,15 +18,10 @@ import "../Components"
PageType { PageType {
id: root id: root
Rectangle {
anchors.fill: parent
z: -1
color: AmneziaStyle.color.onyxBlack
}
property int containerStatus: 1 property int containerStatus: 1
property bool isUpdating: false property bool isUpdating: false
property bool isCheckingStatus: false property bool isCheckingStatus: false
property bool isFetchingSecret: false
property bool previousEnabled: true property bool previousEnabled: true
property int previousContainerStatus: 1 property int previousContainerStatus: 1
@@ -40,6 +35,7 @@ PageType {
property bool previousNatEnabled: false property bool previousNatEnabled: false
property string previousNatInternalIp: "" property string previousNatInternalIp: ""
property string previousNatExternalIp: "" property string previousNatExternalIp: ""
property string previousSecret: ""
property string savedTransportMode: "" property string savedTransportMode: ""
property string savedTlsDomain: "" property string savedTlsDomain: ""
@@ -47,7 +43,7 @@ PageType {
onSavedTransportModeChanged: { onSavedTransportModeChanged: {
if (savedTransportMode === "faketls") { if (savedTransportMode === "faketls") {
root.syncedSecretTabIndex = 2 root.syncedSecretTabIndex = 1
} else if (savedTransportMode !== "") { } else if (savedTransportMode !== "") {
root.syncedSecretTabIndex = 0 root.syncedSecretTabIndex = 0
} }
@@ -64,9 +60,97 @@ PageType {
property string diagStatsEndpoint: "" property string diagStatsEndpoint: ""
readonly property bool telemtNetworkBlocked: !NetworkReachabilityController.hasInternetAccess readonly property bool telemtNetworkBlocked: !NetworkReachabilityController.hasInternetAccess
readonly property bool navigationBlockedWhileBusy: isUpdating || diagLoading
// Defer SSH/updateContainer so QML control handlers return before nested event loops run. property bool remoteOperationBusy: false
readonly property bool operationInProgress: isCheckingStatus || isFetchingSecret || isUpdating || diagLoading
readonly property bool pageBusy: operationInProgress || remoteOperationBusy
readonly property bool navigationBlockedWhileBusy: pageBusy
property bool pageOpenHandled: false
property bool busyIndicatorShown: false
function syncPageBusyIndicator() {
if (!root.pageOpenHandled) {
return
}
var wantBusy = root.pageBusy
if (wantBusy === root.busyIndicatorShown) {
return
}
root.busyIndicatorShown = wantBusy
PageController.showBusyIndicator(wantBusy)
}
onPageBusyChanged: syncPageBusyIndicator()
function telemtDomainToHex(domain) {
var hex = ""
for (var i = 0; i < domain.length; i++) {
var code = domain.charCodeAt(i).toString(16)
hex += (code.length < 2 ? "0" : "") + code
}
return hex
}
function telemtClientSecret(baseHex32, mode, tlsDomain) {
if (baseHex32 === "") {
return ""
}
if (mode === "faketls") {
return "ee" + baseHex32 + telemtDomainToHex(tlsDomain)
}
return "dd" + baseHex32
}
function telemtClientSecretForTabIndex(baseHex32, tabIndex, tlsDomain, defaultTlsDomain) {
var domain = tlsDomain !== "" ? tlsDomain : defaultTlsDomain
if (tabIndex === 1) {
return telemtClientSecret(baseHex32, "faketls", domain)
}
return telemtClientSecret(baseHex32, "standard", domain)
}
property bool containerStatusRefreshCallPending: false
function telemtRequestContainerStatusRefresh() {
if (!NetworkReachabilityController.hasInternetAccess) {
isCheckingStatus = false
syncPageBusyIndicator()
return
}
isCheckingStatus = true
syncPageBusyIndicator()
InstallController.refreshContainerStatus(ServersUiController.processedServerId, ServersUiController.processedContainerIndex)
}
function telemtScheduleContainerStatusRefresh() {
if (containerStatusRefreshCallPending) {
return
}
containerStatusRefreshCallPending = true
Qt.callLater(function () {
containerStatusRefreshCallPending = false
root.telemtRequestContainerStatusRefresh()
})
}
function telemtOnPageShown() {
if (root.pageOpenHandled) {
return
}
root.pageOpenHandled = true
PageController.disableControls(navigationBlockedWhileBusy)
if (!NetworkReachabilityController.hasInternetAccess) {
isCheckingStatus = false
} else {
isCheckingStatus = true
}
syncPageBusyIndicator()
root.telemtScheduleContainerStatusRefresh()
}
function telemtScheduleUpdate(closePage) { function telemtScheduleUpdate(closePage) {
var cp = closePage === undefined ? false : closePage var cp = closePage === undefined ? false : closePage
Qt.callLater(function () { Qt.callLater(function () {
@@ -105,12 +189,7 @@ PageType {
root.savedTlsDomain = TelemtConfigModel.getTlsDomain() root.savedTlsDomain = TelemtConfigModel.getTlsDomain()
root.savedPublicHost = TelemtConfigModel.getPublicHost() root.savedPublicHost = TelemtConfigModel.getPublicHost()
if (!NetworkReachabilityController.hasInternetAccess) { Qt.callLater(root.telemtOnPageShown)
isCheckingStatus = false
return
}
isCheckingStatus = true
InstallController.refreshContainerStatus(ServersUiController.processedServerId, ServersUiController.processedContainerIndex)
} }
onNavigationBlockedWhileBusyChanged: { onNavigationBlockedWhileBusyChanged: {
@@ -121,10 +200,16 @@ PageType {
onVisibleChanged: { onVisibleChanged: {
if (!visible) { if (!visible) {
root.pageOpenHandled = false
containerStatusRefreshCallPending = false
isCheckingStatus = false
isFetchingSecret = false
busyIndicatorShown = false
PageController.disableControls(false) PageController.disableControls(false)
PageController.showBusyIndicator(false)
diagLoading = false diagLoading = false
} else { } else {
PageController.disableControls(navigationBlockedWhileBusy) root.telemtOnPageShown()
} }
} }
@@ -136,8 +221,7 @@ PageType {
return return
} }
if (NetworkReachabilityController.hasInternetAccess) { if (NetworkReachabilityController.hasInternetAccess) {
isCheckingStatus = true root.telemtScheduleContainerStatusRefresh()
InstallController.refreshContainerStatus(ServersUiController.processedServerId, ServersUiController.processedContainerIndex)
} }
} }
} }
@@ -145,10 +229,15 @@ PageType {
Connections { Connections {
target: InstallController target: InstallController
function onServerIsBusy(busy) {
remoteOperationBusy = busy
}
function onUpdateContainerFinished(message, closePage) { function onUpdateContainerFinished(message, closePage) {
if (!root.visible) { if (!root.visible) {
isUpdating = false isUpdating = false
isCheckingStatus = false isCheckingStatus = false
isFetchingSecret = false
return return
} }
isUpdating = false isUpdating = false
@@ -166,9 +255,11 @@ PageType {
if (!root.visible) { if (!root.visible) {
isUpdating = false isUpdating = false
isCheckingStatus = false isCheckingStatus = false
isFetchingSecret = false
return return
} }
isUpdating = false isUpdating = false
isFetchingSecret = false
containerStatus = previousContainerStatus containerStatus = previousContainerStatus
TelemtConfigModel.setEnabled(previousEnabled) TelemtConfigModel.setEnabled(previousEnabled)
TelemtConfigModel.setPort(previousPort) TelemtConfigModel.setPort(previousPort)
@@ -181,6 +272,9 @@ PageType {
TelemtConfigModel.setNatEnabled(previousNatEnabled) TelemtConfigModel.setNatEnabled(previousNatEnabled)
TelemtConfigModel.setNatInternalIp(previousNatInternalIp) TelemtConfigModel.setNatInternalIp(previousNatInternalIp)
TelemtConfigModel.setNatExternalIp(previousNatExternalIp) TelemtConfigModel.setNatExternalIp(previousNatExternalIp)
if (previousSecret !== "") {
TelemtConfigModel.setSecret(previousSecret)
}
} }
function onSetContainerEnabledFinished(enabled) { function onSetContainerEnabledFinished(enabled) {
@@ -190,6 +284,7 @@ PageType {
} }
if (enabled && pendingUpdateAfterEnable) { if (enabled && pendingUpdateAfterEnable) {
pendingUpdateAfterEnable = false pendingUpdateAfterEnable = false
isUpdating = true
root.telemtScheduleUpdate(false) root.telemtScheduleUpdate(false)
return return
} }
@@ -202,9 +297,9 @@ PageType {
function onContainerStatusRefreshed(status) { function onContainerStatusRefreshed(status) {
if (!root.visible) { if (!root.visible) {
isCheckingStatus = false isCheckingStatus = false
isFetchingSecret = false
return return
} }
isCheckingStatus = false
containerStatus = status containerStatus = status
root.savedTransportMode = TelemtConfigModel.getTransportMode() root.savedTransportMode = TelemtConfigModel.getTransportMode()
@@ -212,11 +307,18 @@ PageType {
root.savedPublicHost = TelemtConfigModel.getPublicHost() root.savedPublicHost = TelemtConfigModel.getPublicHost()
if (status === 1) { if (status === 1) {
TelemtConfigModel.setEnabled(true) TelemtConfigModel.setEnabled(true)
isFetchingSecret = true
isCheckingStatus = false
InstallController.fetchContainerSecret(ServersUiController.processedServerId, ServersUiController.processedContainerIndex) InstallController.fetchContainerSecret(ServersUiController.processedServerId, ServersUiController.processedContainerIndex)
} else if (status === 2) { } else {
isFetchingSecret = false
isCheckingStatus = false
if (status === 2) {
TelemtConfigModel.setEnabled(false) TelemtConfigModel.setEnabled(false)
} }
} }
syncPageBusyIndicator()
}
function onContainerDiagnosticsRefreshed(portReachable, upstreamReachable, clientsConnected, lastConfigRefresh, statsEndpoint) { function onContainerDiagnosticsRefreshed(portReachable, upstreamReachable, clientsConnected, lastConfigRefresh, statsEndpoint) {
if (!root.visible) { if (!root.visible) {
@@ -232,20 +334,35 @@ PageType {
function onContainerSecretFetched(secret) { function onContainerSecretFetched(secret) {
if (!root.visible) { if (!root.visible) {
isFetchingSecret = false
return return
} }
isFetchingSecret = false
syncPageBusyIndicator()
TelemtConfigModel.validateAndSetSecret(secret) TelemtConfigModel.validateAndSetSecret(secret)
} }
} }
Item {
id: contentLayer
anchors.fill: parent
enabled: !root.pageBusy
BackButtonType { BackButtonType {
id: backButton id: backButton
anchors.top: parent.top anchors.top: parent.top
anchors.left: parent.left anchors.left: parent.left
anchors.right: parent.right anchors.right: parent.right
anchors.topMargin: 20 + SettingsController.safeAreaTopMargin anchors.topMargin: 20 + PageController.safeAreaTopMargin
onFocusChanged: { onFocusChanged: {
if (this.activeFocus) connectionListView.positionViewAtBeginning() if (this.activeFocus) {
if (mainTabBar.currentIndex === 0) {
connectionListView.positionViewAtBeginning()
} else {
settingsListView.positionViewAtBeginning()
}
}
} }
} }
@@ -254,32 +371,38 @@ PageType {
anchors.top: backButton.bottom anchors.top: backButton.bottom
anchors.left: parent.left anchors.left: parent.left
anchors.right: parent.right anchors.right: parent.right
anchors.topMargin: 8
spacing: 0 spacing: 0
BaseHeaderType { BaseHeaderType {
Layout.fillWidth: true Layout.fillWidth: true
Layout.leftMargin: 16 Layout.leftMargin: 16
Layout.rightMargin: 16 Layout.rightMargin: 16
Layout.topMargin: 8 Layout.bottomMargin: 24
headerText: qsTr("Telemt settings") headerText: qsTr("Telemt settings")
descriptionLinkText: qsTr("Read more about this settings")
descriptionLinkUrl: "https://github.com/telemt/telemt"
} }
LabelWithButtonType { CaptionTextType {
Layout.fillWidth: true Layout.fillWidth: true
Layout.leftMargin: 0 Layout.leftMargin: 16
Layout.rightMargin: 16 Layout.rightMargin: 16
text: qsTr("Read more about this settings") Layout.topMargin: 8
textColor: AmneziaStyle.color.goldenApricot visible: root.telemtNetworkBlocked
clickedFunction: function () { text: qsTr("No internet connection. Connect to the internet to change Telemt settings.")
Qt.openUrlExternally("https://github.com/telemt/telemt") color: AmneziaStyle.color.mutedGray
wrapMode: Text.WordWrap
font.pixelSize: 14
} }
} }
TabBar { TabBar {
id: mainTabBar id: mainTabBar
Layout.fillWidth: true anchors.top: pageHeader.bottom
Layout.topMargin: 4 anchors.left: parent.left
anchors.right: parent.right
width: parent.width
background: Rectangle { background: Rectangle {
color: AmneziaStyle.color.transparent color: AmneziaStyle.color.transparent
@@ -300,11 +423,10 @@ PageType {
isSelected: mainTabBar.currentIndex === 1 isSelected: mainTabBar.currentIndex === 1
} }
} }
}
StackLayout { StackLayout {
id: tabContent id: tabContent
anchors.top: pageHeader.bottom anchors.top: mainTabBar.bottom
anchors.bottom: parent.bottom anchors.bottom: parent.bottom
anchors.left: parent.left anchors.left: parent.left
anchors.right: parent.right anchors.right: parent.right
@@ -318,36 +440,11 @@ PageType {
width: connectionListView.width width: connectionListView.width
spacing: 0 spacing: 0
function domainToHex(domain) {
var hex = ""
for (var i = 0; i < domain.length; i++) {
var code = domain.charCodeAt(i).toString(16)
hex += (code.length < 2 ? "0" : "") + code
}
return hex
}
function secretForMode(mode) {
if (mode === "faketls") {
var domain = root.savedTlsDomain !== "" ? root.savedTlsDomain : TelemtConfigModel.defaultTlsDomain()
return "ee" + secret + domainToHex(domain)
} else if (mode === "padded") {
return "dd" + secret
}
// Telemt default (secure MTProto, not FakeTLS): Telegram proxy links require dd + hex secret
return "dd" + secret
}
property int secretTabIndex: root.syncedSecretTabIndex property int secretTabIndex: root.syncedSecretTabIndex
function activeSecret() { function activeSecret() {
if (root.syncedSecretTabIndex === 0) { return root.telemtClientSecretForTabIndex(secret, root.syncedSecretTabIndex,
return secretForMode("standard") root.savedTlsDomain, TelemtConfigModel.defaultTlsDomain())
}
if (root.syncedSecretTabIndex === 1) {
return secretForMode("padded")
}
return secretForMode("faketls")
} }
function effectiveSecret() { function effectiveSecret() {
@@ -690,6 +787,11 @@ PageType {
width: settingsListView.width width: settingsListView.width
spacing: 0 spacing: 0
function telemtActiveSecretForBaseHex(baseHex) {
return root.telemtClientSecretForTabIndex(baseHex, root.syncedSecretTabIndex,
root.savedTlsDomain, TelemtConfigModel.defaultTlsDomain())
}
SwitcherType { SwitcherType {
id: enableTelemtSwitch id: enableTelemtSwitch
Layout.fillWidth: true Layout.fillWidth: true
@@ -699,11 +801,13 @@ PageType {
Layout.bottomMargin: 16 Layout.bottomMargin: 16
text: qsTr("Enable Telemt") text: qsTr("Enable Telemt")
checked: isEnabled checked: isEnabled
enabled: !isCheckingStatus && containerStatus !== 0 && containerStatus !== 3 && !isUpdating enabled: containerStatus !== 0 && containerStatus !== 3 && !root.pageBusy
&& !root.telemtNetworkBlocked
onToggled: function () { onToggled: function () {
if (checked !== isEnabled) { if (checked !== isEnabled) {
previousEnabled = isEnabled previousEnabled = isEnabled
previousContainerStatus = containerStatus previousContainerStatus = containerStatus
root.previousSecret = secret
isEnabled = checked isEnabled = checked
isUpdating = true isUpdating = true
if (checked) { if (checked) {
@@ -736,13 +840,14 @@ PageType {
CaptionTextType { CaptionTextType {
Layout.fillWidth: true Layout.fillWidth: true
text: secret !== "" ? secret : qsTr("Not generated") text: secret !== "" ? telemtActiveSecretForBaseHex(secret) : qsTr("Not generated")
color: secret !== "" ? AmneziaStyle.color.paleGray : AmneziaStyle.color.mutedGray color: secret !== "" ? AmneziaStyle.color.paleGray : AmneziaStyle.color.mutedGray
elide: Text.ElideMiddle wrapMode: Text.WrapAnywhere
font.pixelSize: 14 font.pixelSize: 14
} }
ImageButtonType { ImageButtonType {
Layout.alignment: Qt.AlignTop
implicitWidth: 36 implicitWidth: 36
implicitHeight: 36 implicitHeight: 36
hoverEnabled: true hoverEnabled: true
@@ -750,12 +855,14 @@ PageType {
imageColor: AmneziaStyle.color.paleGray imageColor: AmneziaStyle.color.paleGray
visible: ServersUiController.isProcessedServerHasWriteAccess() visible: ServersUiController.isProcessedServerHasWriteAccess()
onClicked: { onClicked: {
var secretSnapshot = secret
showQuestionDrawer( showQuestionDrawer(
qsTr("Generate new secret?"), qsTr("Generate new secret?"),
qsTr("All existing connection links will stop working. Users will need new links."), qsTr("All existing connection links will stop working. Users will need new links."),
qsTr("Generate"), qsTr("Generate"),
qsTr("Cancel"), qsTr("Cancel"),
function () { function () {
root.previousSecret = secretSnapshot
if (containerStatus === 1) { if (containerStatus === 1) {
isUpdating = true isUpdating = true
TelemtConfigModel.generateSecret() TelemtConfigModel.generateSecret()
@@ -926,6 +1033,7 @@ PageType {
clickedFunction: function () { clickedFunction: function () {
transportMode = (index === 0) ? "standard" : "faketls" transportMode = (index === 0) ? "standard" : "faketls"
TelemtConfigModel.setTransportMode(transportMode) TelemtConfigModel.setTransportMode(transportMode)
root.syncedSecretTabIndex = transportMode === "faketls" ? 1 : 0
transportModeDropDown.closeTriggered() transportModeDropDown.closeTriggered()
} }
} }
@@ -1406,6 +1514,7 @@ PageType {
previousNatEnabled = natEnabled previousNatEnabled = natEnabled
previousNatInternalIp = natInternalIp previousNatInternalIp = natInternalIp
previousNatExternalIp = natExternalIp previousNatExternalIp = natExternalIp
root.previousSecret = secret
isUpdating = true isUpdating = true
root.telemtScheduleUpdate(false) root.telemtScheduleUpdate(false)
} }
@@ -1414,34 +1523,5 @@ PageType {
} }
} }
Rectangle {
anchors.fill: parent
visible: isCheckingStatus || isUpdating || root.telemtNetworkBlocked
color: AmneziaStyle.color.midnightBlack
opacity: 0.6
z: 1
MouseArea {
anchors.fill: parent
}
BusyIndicator {
anchors.centerIn: parent
visible: isCheckingStatus || isUpdating
running: isCheckingStatus || isUpdating
width: 48
height: 48
}
CaptionTextType {
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: 24
anchors.rightMargin: 24
visible: root.telemtNetworkBlocked && !isCheckingStatus && !isUpdating
horizontalAlignment: Text.AlignHCenter
text: qsTr("No internet connection. Connect to the internet to change Telemt settings.")
color: AmneziaStyle.color.paleGray
wrapMode: Text.WordWrap
font.pixelSize: 14
}
} }
} }