Files
GLEGram-iOS/GLEGram/GLESettingsUI/Sources/PluginInstallPopupController.swift
Leeksov 4647310322 GLEGram 12.5 — Initial public release
Based on Swiftgram 12.5 (Telegram iOS 12.5).
All GLEGram features ported and organized in GLEGram/ folder.

Features: Ghost Mode, Saved Deleted Messages, Content Protection Bypass,
Font Replacement, Fake Profile, Chat Export, Plugin System, and more.

See CHANGELOG_12.5.md for full details.
2026-04-06 09:48:12 +03:00

359 lines
15 KiB
Swift
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// MARK: Swiftgram Plugin install popup (tap .plugin file in chat)
import Foundation
import UIKit
import Display
import AsyncDisplayKit
import SwiftSignalKit
import Postbox
import TelegramCore
import TelegramPresentationData
import AccountContext
import SGSimpleSettings
import AppBundle
private func loadInstalledPlugins() -> [PluginInfo] {
guard let data = SGSimpleSettings.shared.installedPluginsJson.data(using: .utf8),
let list = try? JSONDecoder().decode([PluginInfo].self, from: data) else {
return []
}
return list
}
private func saveInstalledPlugins(_ plugins: [PluginInfo]) {
if let data = try? JSONEncoder().encode(plugins),
let json = String(data: data, encoding: .utf8) {
SGSimpleSettings.shared.installedPluginsJson = json
SGSimpleSettings.shared.synchronizeShared()
}
}
/// Modal popup when user taps a .plugin file in chat: shows plugin info and "Install" button.
public final class PluginInstallPopupController: ViewController {
private let context: AccountContext
private let message: Message
private let file: TelegramMediaFile
private var onInstalled: (() -> Void)?
private var loadDisposable: Disposable?
private var state: State = .loading {
didSet { applyState() }
}
private enum State {
case loading
case loaded(metadata: PluginMetadata, hasSettings: Bool, filePath: String)
case error(String)
}
private let contentNode: PluginInstallPopupContentNode
public init(context: AccountContext, message: Message, file: TelegramMediaFile, onInstalled: (() -> Void)? = nil) {
self.context = context
self.message = message
self.file = file
self.onInstalled = onInstalled
self.contentNode = PluginInstallPopupContentNode()
super.init(navigationBarPresentationData: nil)
self.blocksBackgroundWhenInOverlay = true
}
required public init(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
loadDisposable?.dispose()
}
override public func loadDisplayNode() {
self.displayNode = contentNode
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
contentNode.backgroundColor = presentationData.theme.list.itemBlocksBackgroundColor
contentNode.controller = self
contentNode.installAction = { [weak self] enableAfterInstall in
self?.performInstall(enableAfterInstall: enableAfterInstall)
}
contentNode.closeAction = { [weak self] in
self?.dismiss()
}
contentNode.shareAction = { [weak self] in
self?.sharePlugin()
}
self.navigationItem.leftBarButtonItem = UIBarButtonItem(title: presentationData.strings.Common_Close, style: .plain, target: self, action: #selector(closeTapped))
self.navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .action, target: self, action: #selector(shareTapped))
applyState()
startLoading()
}
@objc private func closeTapped() {
dismiss()
}
@objc private func shareTapped() {
sharePlugin()
}
override public func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
}
private func startLoading() {
let postbox = context.account.postbox
let resource = file.resource
loadDisposable?.dispose()
loadDisposable = (postbox.mediaBox.resourceData(resource, option: .complete(waitUntilFetchStatus: true))
|> filter { $0.complete }
|> take(1)
|> deliverOnMainQueue
).start(next: { [weak self] data in
guard let self = self else { return }
guard let content = try? String(contentsOfFile: data.path, encoding: .utf8) else {
self.state = .error("Не удалось прочитать файл")
return
}
guard let metadata = currentPluginRuntime.parseMetadata(content: content) else {
self.state = .error("Неверный формат плагина")
return
}
let hasSettings = currentPluginRuntime.hasCreateSettings(content: content)
self.state = .loaded(metadata: metadata, hasSettings: hasSettings, filePath: data.path)
})
}
private func applyState() {
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
switch state {
case .loading:
contentNode.setLoading(presentationData: presentationData)
case .loaded(let metadata, let hasSettings, _):
contentNode.setLoaded(presentationData: presentationData, metadata: metadata, hasSettings: hasSettings)
case .error(let message):
contentNode.setError(presentationData: presentationData, message: message, retry: { [weak self] in
self?.state = .loading
self?.startLoading()
})
}
}
private func performInstall(enableAfterInstall: Bool) {
guard case .loaded(let metadata, let hasSettings, let filePath) = state else { return }
let fileManager = FileManager.default
guard let supportURL = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { return }
let pluginsDir = supportURL.appendingPathComponent("Plugins", isDirectory: true)
let destPath = pluginsDir.appendingPathComponent("\(metadata.id).plugin").path
do {
try fileManager.createDirectory(at: pluginsDir, withIntermediateDirectories: true)
let destURL = URL(fileURLWithPath: destPath)
try? fileManager.removeItem(at: destURL)
try fileManager.copyItem(at: URL(fileURLWithPath: filePath), to: destURL)
} catch {
contentNode.showError("Не удалось установить: \(error.localizedDescription)")
return
}
var plugins = loadInstalledPlugins()
plugins.removeAll { $0.metadata.id == metadata.id }
plugins.append(PluginInfo(metadata: metadata, path: destPath, enabled: enableAfterInstall, hasSettings: hasSettings))
saveInstalledPlugins(plugins)
onInstalled?()
dismiss()
}
private func sharePlugin() {
guard case .loaded(_, _, let filePath) = state else { return }
let url = URL(fileURLWithPath: filePath)
let activityVC = UIActivityViewController(activityItems: [url], applicationActivities: nil)
if let window = self.view.window, let root = window.rootViewController {
var top = root
while let presented = top.presentedViewController { top = presented }
if let popover = activityVC.popoverPresentationController {
popover.sourceView = view
popover.sourceRect = CGRect(x: view.bounds.midX, y: 60, width: 0, height: 0)
popover.permittedArrowDirections = .up
}
top.present(activityVC, animated: true)
}
}
}
// MARK: - Content node (icon, name, version, description, Install, checkbox)
private final class PluginInstallPopupContentNode: ViewControllerTracingNode {
weak var controller: PluginInstallPopupController?
var installAction: ((Bool) -> Void)?
var closeAction: (() -> Void)?
var shareAction: (() -> Void)?
var retryBlock: (() -> Void)?
private let scrollNode = ASScrollNode()
private let iconNode = ASImageNode()
private let nameNode = ImmediateTextNode()
private let versionNode = ImmediateTextNode()
private let descriptionNode = ImmediateTextNode()
private let installButton = ASButtonNode()
private let enableAfterContainer = ASDisplayNode()
private let enableAfterLabel = ImmediateTextNode()
private let loadingNode = ASDisplayNode()
private let loadingIndicator = UIActivityIndicatorView(style: .medium)
private let errorLabel = ImmediateTextNode()
private let retryButton = ASButtonNode()
private var enableAfterInstall: Bool = true
private var currentMetadata: PluginMetadata?
private var switchView: UISwitch?
override init() {
super.init()
addSubnode(scrollNode)
scrollNode.addSubnode(iconNode)
scrollNode.addSubnode(nameNode)
scrollNode.addSubnode(versionNode)
scrollNode.addSubnode(descriptionNode)
scrollNode.addSubnode(installButton)
scrollNode.addSubnode(enableAfterContainer)
scrollNode.addSubnode(enableAfterLabel)
addSubnode(loadingNode)
addSubnode(errorLabel)
addSubnode(retryButton)
iconNode.contentMode = .scaleAspectFit
installButton.addTarget(self, action: #selector(installTapped), forControlEvents: .touchUpInside)
retryButton.addTarget(self, action: #selector(retryTapped), forControlEvents: .touchUpInside)
}
func setLoading(presentationData: PresentationData) {
backgroundColor = presentationData.theme.list.itemBlocksBackgroundColor
loadingNode.isHidden = false
loadingNode.view.addSubview(loadingIndicator)
loadingIndicator.startAnimating()
scrollNode.isHidden = true
errorLabel.isHidden = true
retryButton.isHidden = true
}
func setLoaded(presentationData: PresentationData, metadata: PluginMetadata, hasSettings: Bool) {
backgroundColor = presentationData.theme.list.itemBlocksBackgroundColor
currentMetadata = metadata
loadingNode.isHidden = true
loadingIndicator.stopAnimating()
errorLabel.isHidden = true
retryButton.isHidden = true
scrollNode.isHidden = false
let theme = presentationData.theme
let lang = presentationData.strings.baseLanguageCode
let isRu = lang == "ru"
iconNode.image = (metadata.iconRef.flatMap { UIImage(bundleImageName: $0) }) ?? UIImage(bundleImageName: "glePlugins/1")
nameNode.attributedText = NSAttributedString(string: metadata.name, font: Font.bold(22), textColor: theme.list.itemPrimaryTextColor)
nameNode.maximumNumberOfLines = 1
nameNode.truncationMode = .byTruncatingTail
let versionAuthor = (isRu ? "Версия " : "Version ") + "\(metadata.version)" + (metadata.author.isEmpty ? "" : "\(metadata.author)")
versionNode.attributedText = NSAttributedString(string: versionAuthor, font: Font.regular(15), textColor: theme.list.itemSecondaryTextColor)
versionNode.maximumNumberOfLines = 1
descriptionNode.attributedText = NSAttributedString(string: metadata.description.isEmpty ? (isRu ? "Нет описания." : "No description.") : metadata.description, font: Font.regular(15), textColor: theme.list.itemPrimaryTextColor)
descriptionNode.maximumNumberOfLines = 6
descriptionNode.truncationMode = .byTruncatingTail
installButton.setTitle(isRu ? "Установить" : "Install", with: Font.semibold(17), with: .white, for: .normal)
installButton.backgroundColor = theme.list.itemAccentColor
installButton.cornerRadius = 12
installButton.contentEdgeInsets = UIEdgeInsets(top: 14, left: 24, bottom: 14, right: 24)
enableAfterLabel.attributedText = NSAttributedString(string: isRu ? "Включить после установки" : "Enable after installation", font: Font.regular(16), textColor: theme.list.itemPrimaryTextColor)
enableAfterLabel.maximumNumberOfLines = 1
if switchView == nil {
let sw = UISwitch()
sw.isOn = enableAfterInstall
sw.addTarget(self, action: #selector(enableAfterChanged(_:)), for: .valueChanged)
enableAfterContainer.view.addSubview(sw)
switchView = sw
}
switchView?.isOn = enableAfterInstall
layoutContent()
}
@objc private func enableAfterChanged(_ sender: UISwitch) {
enableAfterInstall = sender.isOn
}
func setError(presentationData: PresentationData, message: String, retry: @escaping () -> Void) {
backgroundColor = presentationData.theme.list.itemBlocksBackgroundColor
retryBlock = retry
currentMetadata = nil
loadingNode.isHidden = true
scrollNode.isHidden = true
errorLabel.isHidden = false
retryButton.isHidden = false
errorLabel.attributedText = NSAttributedString(string: message, font: Font.regular(16), textColor: presentationData.theme.list.itemDestructiveColor)
let retryTitle = (presentationData.strings.baseLanguageCode == "ru" ? "Повторить" : "Retry")
retryButton.setTitle(retryTitle, with: Font.regular(17), with: presentationData.theme.list.itemAccentColor, for: .normal)
layoutContent()
}
func showError(_ message: String) {
errorLabel.attributedText = NSAttributedString(string: message, font: Font.regular(16), textColor: .red)
errorLabel.isHidden = false
errorLabel.frame = CGRect(x: 24, y: 120, width: bounds.width - 48, height: 60)
}
@objc private func installTapped() {
installAction?(enableAfterInstall)
}
@objc private func retryTapped() {
guard let retry = retryBlock else { return }
retry()
}
private func layoutContent() {
let b = bounds
let w = b.width > 0 ? b.width : 320
let pad: CGFloat = 24
loadingIndicator.center = CGPoint(x: b.midX, y: b.midY)
loadingNode.frame = b
errorLabel.frame = CGRect(x: pad, y: b.midY - 40, width: w - pad * 2, height: 60)
retryButton.frame = CGRect(x: pad, y: b.midY + 20, width: w - pad * 2, height: 44)
scrollNode.frame = b
let contentW = w - pad * 2
iconNode.frame = CGRect(x: pad, y: 20, width: 56, height: 56)
nameNode.frame = CGRect(x: pad, y: 86, width: contentW, height: 28)
versionNode.frame = CGRect(x: pad, y: 118, width: contentW, height: 22)
let descY: CGFloat = 150
let descMaxH: CGFloat = 80
if let att = descriptionNode.attributedText {
let descSize = att.boundingRect(with: CGSize(width: contentW, height: descMaxH), options: .usesLineFragmentOrigin, context: nil).size
descriptionNode.frame = CGRect(x: pad, y: descY, width: contentW, height: min(descMaxH, ceil(descSize.height)))
} else {
descriptionNode.frame = CGRect(x: pad, y: descY, width: contentW, height: 22)
}
let buttonY: CGFloat = 240
installButton.frame = CGRect(x: pad, y: buttonY, width: contentW, height: 50)
let rowY: CGFloat = 306
let switchW: CGFloat = 51
let switchH: CGFloat = 31
enableAfterLabel.frame = CGRect(x: pad, y: rowY, width: contentW - switchW - 12, height: 24)
enableAfterContainer.frame = CGRect(x: w - pad - switchW, y: rowY, width: switchW, height: switchH)
switchView?.frame = CGRect(origin: .zero, size: CGSize(width: switchW, height: switchH))
let contentHeight: CGFloat = 360
scrollNode.view.contentSize = CGSize(width: w, height: contentHeight)
}
override func layout() {
super.layout()
layoutContent()
}
}