Files
GLEGram-iOS/docs/PLUGIN_SYSTEM_FULL.md
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

6606 lines
336 KiB
Markdown
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.
# GLEGram: полная реализация JS-плагинов (Swiftgram)
Документ содержит **полные исходные тексты** всех Swift-файлов в **Swiftgram**, которые относятся к системе плагинов: хуки, мост `PluginHost`, загрузчик `JavaScriptCore`, хранение в `SimpleSettings`, экраны списка/редактора/настроек плагина, строка списка плагинов, интеграция в GLEGram/SG settings.
Интеграция в **Telegram UI** (`submodules/TelegramUI`) — отдельный файл **`PLUGIN_SYSTEM_TELEGRAM_FULL.md`** (там все места, где вызываются `SGPluginHooks`, `PluginHost`, `PluginRunner`, `pluginsJavaScriptBridgeActive`).
## Оглавление (Swiftgram, этот файл)
1. `Swiftgram/SGSimpleSettings/Sources/GLEGramFeatures.swift`
2. `Swiftgram/SGSimpleSettings/Sources/PluginHooks.swift`
3. `Swiftgram/SGSimpleSettings/Sources/PluginHost.swift`
4. `Swiftgram/SGSimpleSettings/Sources/SimpleSettings.swift`
5. `Swiftgram/SGSettingsUI/Sources/PluginMetadata.swift`
6. `Swiftgram/SGSettingsUI/Sources/PluginBridge.swift`
7. `Swiftgram/SGSettingsUI/Sources/PluginBridgePythonKit.swift`
8. `Swiftgram/SGSettingsUI/Sources/ItemListPluginRowItem.swift`
9. `Swiftgram/SGSettingsUI/Sources/PluginListController.swift`
10. `Swiftgram/SGSettingsUI/Sources/PluginCodeEditorController.swift`
11. `Swiftgram/SGSettingsUI/Sources/PluginInstallPopupController.swift`
12. `Swiftgram/SGSettingsUI/Sources/PluginSettingsController.swift`
13. `Swiftgram/SGSettingsUI/Sources/PluginRunner.swift`
14. `Swiftgram/SGSettingsUI/Sources/GLEGramSettingsController.swift`
15. `Swiftgram/SGSettingsUI/Sources/SGSettingsController.swift`
---
## Полные файлы
### `Swiftgram/SGSimpleSettings/Sources/GLEGramFeatures.swift`
```swift
import Foundation
/// Глобальные флаги функций GLEGram (Swiftgram).
public enum GLEGramFeatures {
/// Мастер-переключатель JS-плагинов отключён: без `PluginRunner`, без хуков в чате (меньше нагрузка и зависаний).
public static let pluginsEnabled = true
}
```
### `Swiftgram/SGSimpleSettings/Sources/PluginHooks.swift`
```swift
// MARK: GLEGram Plugin hooks (simplified, Ghostgram-style API)
import Foundation
// MARK: - Outgoing message intercept
public enum SGPluginHookStrategy: String, Codable, Sendable {
case passthrough
case modify
case cancel
}
public struct SGPluginHookResult: Codable, Sendable, Equatable {
public var strategy: SGPluginHookStrategy
public var message: String?
public init(strategy: SGPluginHookStrategy = .passthrough, message: String? = nil) {
self.strategy = strategy
self.message = message
}
}
/// Runner for outgoing message intercept: (accountPeerId, peerId, text, replyToMessageId?) result
public typealias PluginMessageHookRunner = (Int64, Int64, String, Int64?) -> SGPluginHookResult?
/// Runner for incoming message notification: (accountId, peerId, messageId, text?, outgoing)
public typealias PluginIncomingMessageRunner = (Int64, Int64, Int64, String?, Bool) -> Void
// MARK: - Context menu items
public struct PluginChatMenuItem: Sendable {
public let title: String
public let action: @Sendable () -> Void
public init(title: String, action: @escaping @Sendable () -> Void) {
self.title = title
self.action = action
}
}
// MARK: - Notification names
/// Posted by TelegramCore on new message (userInfo: accountId, peerId, messageId, text, outgoing).
public let SGPluginIncomingMessageNotificationName = Notification.Name("SGPluginIncomingMessage")
/// Universal technical event (userInfo: eventName: String, params: [String: Any]).
public let SGPluginTechnicalEventNotificationName = Notification.Name("SGPluginTechnicalEvent")
// MARK: - Hook providers
/// All plugin hooks set by PluginRunner, called by TelegramUI.
public enum SGPluginHooks {
// Intercept
public static var messageHookRunner: PluginMessageHookRunner?
public static var didSendMessageRunner: ((Int64, Int64, String) -> Void)?
public static var incomingMessageHookRunner: PluginIncomingMessageRunner?
// Context menus
public static var chatMenuItemsProvider: ((Int64, Int64, Int64?) -> [PluginChatMenuItem])?
public static var profileMenuItemsProvider: ((Int64, Int64) -> [PluginChatMenuItem])?
// Navigation
public static var willOpenChatRunner: ((Int64, Int64) -> Void)?
public static var willOpenProfileRunner: ((Int64, Int64) -> Void)?
// URL
public static var openUrlRunner: ((String) -> Bool)?
// Message filtering
public static var shouldShowMessageRunner: ((Int64, Int64, Int64, String?, Bool) -> Bool)?
public static var shouldShowGiftButtonRunner: ((Int64, Int64) -> Bool)?
// User display (Fake Profile)
public static var userDisplayRunner: PluginUserDisplayRunner?
// Events
public static var eventRunner: ((String, [String: Any]) -> [String: Any]?)?
// MARK: - Convenience
public static func applyOutgoingMessageTextHooks(
accountPeerId: Int64,
peerId: Int64,
text: String,
replyToMessageId: Int64? = nil
) -> SGPluginHookResult {
guard let runner = messageHookRunner else { return SGPluginHookResult() }
return runner(accountPeerId, peerId, text, replyToMessageId) ?? SGPluginHookResult()
}
public static func applyOpenUrlHook(url: String) -> Bool {
return openUrlRunner?(url) ?? false
}
public static func applyShouldShowMessageHook(accountId: Int64, peerId: Int64, messageId: Int64, text: String?, outgoing: Bool) -> Bool {
return shouldShowMessageRunner?(accountId, peerId, messageId, text, outgoing) ?? true
}
public static func applyShouldShowGiftButtonHook(accountId: Int64, peerId: Int64) -> Bool {
return shouldShowGiftButtonRunner?(accountId, peerId) ?? true
}
public static func emitEvent(_ name: String, _ params: [String: Any]) -> [String: Any]? {
return eventRunner?(name, params)
}
}
// MARK: - User display (kept for Fake Profile compatibility)
public struct PluginDisplayUser: Equatable, Sendable {
public var firstName: String
public var lastName: String
public var username: String?
public var phone: String?
public var id: Int64
public var isPremium: Bool
public var isVerified: Bool
public var isScam: Bool
public var isFake: Bool
public var isSupport: Bool
public var isBot: Bool
public init(firstName: String, lastName: String, username: String?, phone: String?, id: Int64, isPremium: Bool, isVerified: Bool, isScam: Bool, isFake: Bool, isSupport: Bool, isBot: Bool) {
self.firstName = firstName
self.lastName = lastName
self.username = username
self.phone = phone
self.id = id
self.isPremium = isPremium
self.isVerified = isVerified
self.isScam = isScam
self.isFake = isFake
self.isSupport = isSupport
self.isBot = isBot
}
}
public typealias PluginUserDisplayRunner = (Int64, PluginDisplayUser) -> PluginDisplayUser?
// MARK: - ReplyMessageInfo (kept for hook compatibility)
public struct ReplyMessageInfo: Sendable {
public let messageId: Int64
public let isDocument: Bool
public let filePath: String?
public let fileName: String?
public let mimeType: String?
public init(messageId: Int64, isDocument: Bool, filePath: String?, fileName: String?, mimeType: String?) {
self.messageId = messageId
self.isDocument = isDocument
self.filePath = filePath
self.fileName = fileName
self.mimeType = mimeType
}
}
```
### `Swiftgram/SGSimpleSettings/Sources/PluginHost.swift`
```swift
// MARK: GLEGram Plugin host (callbacks from plugins into iOS UI)
// Ghostgram-style API: GLEGram.ui, GLEGram.compose, GLEGram.chat, GLEGram.network, etc.
import Foundation
/// Toast/bulletin type.
public enum PluginBulletinType {
case info
case error
case success
}
/// Host callbacks set by app so plugins can interact with UI and Telegram.
public final class PluginHost {
public static let shared = PluginHost()
// MARK: - GLEGram.ui
/// Show alert (title, message).
public var showAlert: ((String, String) -> Void)?
/// Show prompt with text field (title, placeholder, callback with entered text or nil if cancelled).
public var showPrompt: ((String, String, @escaping (String?) -> Void) -> Void)?
/// Show confirm dialog OK/Cancel. Callback: true = OK, false = Cancel.
public var showConfirm: ((String, String, @escaping (Bool) -> Void) -> Void)?
/// Copy text to system clipboard.
public var copyToClipboard: ((String) -> Void)?
/// Show system share sheet for text.
public var shareText: ((String) -> Void)?
/// Haptic feedback: "success", "warning", "error", "light", "medium", "heavy".
public var haptic: ((String) -> Void)?
/// Open URL in browser or Telegram.
public var openURL: ((String) -> Void)?
/// Show toast/bulletin.
public var showBulletin: ((String, PluginBulletinType) -> Void)?
/// Simple toast (falls back to bulletin with .info).
public var showToast: ((String) -> Void)?
// MARK: - GLEGram.compose
/// Get text from current chat input field.
public var getInputText: (() -> String)?
/// Set text in current chat input field.
public var setInputText: ((String) -> Void)?
/// Insert text at cursor position in current chat input field.
public var insertText: ((String) -> Void)?
/// Send current input message.
public var sendInputMessage: (() -> Void)?
/// Register callback for message submit (called before sending).
public var onSubmitCallback: ((@escaping (String) -> Void) -> Void)?
// MARK: - GLEGram.chat
/// Returns (accountId, peerId) of currently open chat, or nil.
public var getCurrentChat: (() -> (accountId: Int64, peerId: Int64)?)?
/// Send message: (accountPeerId, peerId, text, replyToMessageId?, filePath?).
public var sendMessage: ((Int64, Int64, String, Int64?, String?) -> Void)?
/// Open chat by peerId.
public var openChat: ((Int64) -> Void)?
/// Edit message: (accountId, peerId, messageId, newText).
public var editMessage: ((Int64, Int64, Int64, String) -> Void)?
/// Delete message: (accountId, peerId, messageId).
public var deleteMessage: ((Int64, Int64, Int64) -> Void)?
// MARK: - GLEGram.network
/// Fetch URL: (url, method, headers, body, callback(error?, responseString?)).
public var fetch: ((String, String, [String: String]?, String?, @escaping (String?, String?) -> Void) -> Void)?
// MARK: - Threading
public var runOnMain: ((@escaping () -> Void) -> Void)?
public var runOnBackground: ((@escaping () -> Void) -> Void)?
// MARK: - GLEGram.settings (per-plugin storage)
private let pluginSettingsPrefix = "sg_plugin_"
public func getPluginSetting(pluginId: String, key: String) -> String? {
let k = "\(pluginSettingsPrefix)\(pluginId)_\(key)"
return UserDefaults.standard.string(forKey: k)
}
public func getPluginSettingBool(pluginId: String, key: String, default defaultValue: Bool) -> Bool {
guard let s = getPluginSetting(pluginId: pluginId, key: key) else { return defaultValue }
return s == "1" || s.lowercased() == "true"
}
public func setPluginSetting(pluginId: String, key: String, value: String) {
UserDefaults.standard.set(value, forKey: "\(pluginSettingsPrefix)\(pluginId)_\(key)")
}
public func setPluginSettingBool(pluginId: String, key: String, value: Bool) {
setPluginSetting(pluginId: pluginId, key: key, value: value ? "1" : "0")
}
/// Temp directory for a plugin.
public func getPluginTempDirectory(pluginId: String) -> String {
let dir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first?
.appendingPathComponent("Plugins", isDirectory: true)
.appendingPathComponent(pluginId, isDirectory: true).path ?? NSTemporaryDirectory()
try? FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
return dir
}
private init() {
runOnMain = { block in DispatchQueue.main.async(execute: block) }
runOnBackground = { block in DispatchQueue.global(qos: .userInitiated).async(execute: block) }
// Default fetch implementation using URLSession
fetch = { url, method, headers, body, callback in
guard let requestURL = URL(string: url) else {
callback("Invalid URL", nil)
return
}
var request = URLRequest(url: requestURL, timeoutInterval: 30)
request.httpMethod = method.isEmpty ? "GET" : method.uppercased()
if let headers = headers {
for (key, value) in headers {
request.setValue(value, forHTTPHeaderField: key)
}
}
if let body = body, !body.isEmpty {
request.httpBody = body.data(using: .utf8)
}
URLSession.shared.dataTask(with: request) { data, response, error in
if let error = error {
DispatchQueue.main.async { callback(error.localizedDescription, nil) }
return
}
let responseString = data.flatMap { String(data: $0, encoding: .utf8) } ?? ""
DispatchQueue.main.async { callback(nil, responseString) }
}.resume()
}
// Default haptic implementation
haptic = { style in
DispatchQueue.main.async {
switch style {
case "success":
let gen = UINotificationFeedbackGenerator()
gen.notificationOccurred(.success)
case "warning":
let gen = UINotificationFeedbackGenerator()
gen.notificationOccurred(.warning)
case "error":
let gen = UINotificationFeedbackGenerator()
gen.notificationOccurred(.error)
case "heavy":
let gen = UIImpactFeedbackGenerator(style: .heavy)
gen.impactOccurred()
case "medium":
let gen = UIImpactFeedbackGenerator(style: .medium)
gen.impactOccurred()
default:
let gen = UIImpactFeedbackGenerator(style: .light)
gen.impactOccurred()
}
}
}
}
}
// UIKit import for haptic generators
import UIKit
```
### `Swiftgram/SGSimpleSettings/Sources/SimpleSettings.swift`
```swift
import Foundation
import SGAppGroupIdentifier
import SGLogging
let APP_GROUP_IDENTIFIER = sgAppGroupIdentifier()
/// Lightweight file-backed key-value store (replaces NSUserDefaults for sensitive keys).
private class SGFileStore {
static let shared = SGFileStore()
private var data: [String: Any] = [:]
private let filePath: String
private let lock = NSLock()
private init() {
let docs = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true).first ?? NSTemporaryDirectory()
filePath = docs + "/sg_private_settings.plist"
if let dict = NSDictionary(contentsOfFile: filePath) as? [String: Any] {
data = dict
}
}
func double(forKey key: String, default defaultValue: Double) -> Double {
lock.lock()
defer { lock.unlock() }
return data[key] as? Double ?? defaultValue
}
func int32(forKey key: String, default defaultValue: Int32) -> Int32 {
lock.lock()
defer { lock.unlock() }
if let v = data[key] as? Int { return Int32(v) }
if let v = data[key] as? Int32 { return v }
return defaultValue
}
func set(_ value: Any, forKey key: String) {
lock.lock()
data[key] = value
(data as NSDictionary).write(toFile: filePath, atomically: true)
lock.unlock()
}
}
public class SGSimpleSettings {
public static let shared = SGSimpleSettings()
/// When > 0, outgoing message read receipts are sent even if ghost mode (`disableMessageReadReceipt`) is enabled.
private static let outgoingMessageReadReceiptBypassLock = NSLock()
private static var outgoingMessageReadReceiptBypassDepth: Int = 0
/// Read-state sync runs after the interactive transaction; allow pushes for a short window after user-triggered read.
private static var readReceiptBypassUntilTimestamp: CFTimeInterval = 0
/// True while ``performWithOutgoingMessageReadReceiptsAllowed(_:)`` is running (e.g. user chose «Read» or replied).
public static var isOutgoingMessageReadReceiptBypassActive: Bool {
outgoingMessageReadReceiptBypassLock.lock()
defer { outgoingMessageReadReceiptBypassLock.unlock() }
return outgoingMessageReadReceiptBypassDepth > 0
}
/// True shortly after a user explicitly allowed read receipts (covers async `SynchronizePeerReadState`).
public static var isOutgoingReadReceiptTimeBypassActive: Bool {
outgoingMessageReadReceiptBypassLock.lock()
defer { outgoingMessageReadReceiptBypassLock.unlock() }
return CFAbsoluteTimeGetCurrent() < readReceiptBypassUntilTimestamp
}
/// Extends the time window during which read receipts may be sent despite ghost mode.
public static func extendOutgoingReadReceiptBypassTimeWindow(seconds: CFTimeInterval = 8.0) {
outgoingMessageReadReceiptBypassLock.lock()
let until = CFAbsoluteTimeGetCurrent() + seconds
if until > readReceiptBypassUntilTimestamp {
readReceiptBypassUntilTimestamp = until
}
outgoingMessageReadReceiptBypassLock.unlock()
}
/// Run `body` while allowing read receipts to be pushed to the server.
public static func performWithOutgoingMessageReadReceiptsAllowed(_ body: () -> Void) {
extendOutgoingReadReceiptBypassTimeWindow(seconds: 8.0)
outgoingMessageReadReceiptBypassLock.lock()
outgoingMessageReadReceiptBypassDepth += 1
outgoingMessageReadReceiptBypassLock.unlock()
defer {
outgoingMessageReadReceiptBypassLock.lock()
outgoingMessageReadReceiptBypassDepth -= 1
outgoingMessageReadReceiptBypassLock.unlock()
}
body()
}
/// Combined bypass used by TelegramCore when ghost mode blocks read receipts.
public static var allowsOutgoingMessageReadReceiptDespiteGhostMode: Bool {
isOutgoingMessageReadReceiptBypassActive || isOutgoingReadReceiptTimeBypassActive
}
private init() {
setDefaultValues()
migrate()
preCacheValues()
}
private func setDefaultValues() {
UserDefaults.standard.register(defaults: SGSimpleSettings.defaultValues)
// Just in case group defaults will be nil
UserDefaults.standard.register(defaults: SGSimpleSettings.groupDefaultValues)
if let groupUserDefaults = UserDefaults(suiteName: APP_GROUP_IDENTIFIER) {
groupUserDefaults.register(defaults: SGSimpleSettings.groupDefaultValues)
}
}
private func migrate() {
let showRepostToStoryMigrationKey = "migrated_\(Keys.showRepostToStory.rawValue)"
if let groupUserDefaults = UserDefaults(suiteName: APP_GROUP_IDENTIFIER) {
if !groupUserDefaults.bool(forKey: showRepostToStoryMigrationKey) {
self.showRepostToStoryV2 = self.showRepostToStory
groupUserDefaults.set(true, forKey: showRepostToStoryMigrationKey)
SGLogger.shared.log("SGSimpleSettings", "Migrated showRepostToStory. \(self.showRepostToStory) -> \(self.showRepostToStoryV2)")
}
} else {
SGLogger.shared.log("SGSimpleSettings", "Unable to migrate showRepostToStory. Shared UserDefaults suite is not available for '\(APP_GROUP_IDENTIFIER)'.")
}
// MARK: AppBadge default migration
// Older builds used an empty value which resulted in the classic badge being shown.
if self.customAppBadge.isEmpty || self.customAppBadge == "Components/AppBadge" {
self.customAppBadge = "SkyAppBadge"
}
}
private func preCacheValues() {
// let dispatchGroup = DispatchGroup()
let tasks = [
// { let _ = self.allChatsFolderPositionOverride },
{ let _ = self.tabBarSearchEnabled },
{ let _ = self.allChatsHidden },
{ let _ = self.hideTabBar },
{ let _ = self.bottomTabStyle },
{ let _ = self.compactChatList },
{ let _ = self.compactFolderNames },
{ let _ = self.disableSwipeToRecordStory },
{ let _ = self.rememberLastFolder },
{ let _ = self.quickTranslateButton },
{ let _ = self.stickerSize },
{ let _ = self.stickerTimestamp },
{ let _ = self.disableGalleryCamera },
{ let _ = self.disableSendAsButton },
{ let _ = self.disableSnapDeletionEffect },
{ let _ = self.startTelescopeWithRearCam },
{ let _ = self.hideRecordingButton },
{ let _ = self.inputToolbar },
{ let _ = self.dismissedSGSuggestions },
{ let _ = self.customAppBadge }
]
tasks.forEach { task in
DispatchQueue.global(qos: .background).async(/*group: dispatchGroup*/) {
task()
}
}
// dispatchGroup.notify(queue: DispatchQueue.main) {}
}
public func synchronizeShared() {
if let groupUserDefaults = UserDefaults(suiteName: APP_GROUP_IDENTIFIER) {
groupUserDefaults.synchronize()
}
}
public enum Keys: String, CaseIterable {
case hidePhoneInSettings
case showTabNames
case startTelescopeWithRearCam
case accountColorsSaturation
case uploadSpeedBoost
case downloadSpeedBoost
case bottomTabStyle
case rememberLastFolder
case lastAccountFolders
case localDNSForProxyHost
case sendLargePhotos
case outgoingPhotoQuality
case storyStealthMode
case canUseStealthMode
case disableSwipeToRecordStory
case quickTranslateButton
case outgoingLanguageTranslation
case showRepostToStory
case showRepostToStoryV2
case contextShowSelectFromUser
case contextShowSaveToCloud
case contextShowRestrict
// case contextShowBan
case contextShowHideForwardName
case contextShowReport
case contextShowReply
case contextShowPin
case contextShowSaveMedia
case contextShowMessageReplies
case contextShowJson
case disableScrollToNextChannel
case disableScrollToNextTopic
case disableChatSwipeOptions
case disableDeleteChatSwipeOption
case disableGalleryCamera
case disableGalleryCameraPreview
case disableSendAsButton
case disableSnapDeletionEffect
case stickerSize
case stickerTimestamp
case hideRecordingButton
case hideTabBar
case showDC
case showCreationDate
case showRegDate
case regDateCache
case compactChatList
case compactFolderNames
case allChatsTitleLengthOverride
// case allChatsFolderPositionOverride
case allChatsHidden
case defaultEmojisFirst
case messageDoubleTapActionOutgoing
case wideChannelPosts
case forceEmojiTab
case forceBuiltInMic
case secondsInMessages
case hideChannelBottomButton
case forceSystemSharing
case confirmCalls
case videoPIPSwipeDirection
case legacyNotificationsFix
case messageFilterKeywords
case inputToolbar
case pinnedMessageNotifications
case mentionsAndRepliesNotifications
case primaryUserId
case status
case dismissedSGSuggestions
case duckyAppIconAvailable
case transcriptionBackend
case translationBackend
case customAppBadge
case canUseNY
case nyStyle
case wideTabBar
case tabBarSearchEnabled
case showDeletedMessages
case saveEditHistory
// MARK: Saved Deleted Messages (AyuGram-style)
case saveDeletedMessagesMedia
case saveDeletedMessagesReactions
case saveDeletedMessagesForBots
// Ghost Mode settings
case ghostModeMessageSendDelaySeconds
case disableOnlineStatus
case disableTypingStatus
case disableRecordingVideoStatus
case disableUploadingVideoStatus
case disableVCMessageRecordingStatus
case disableVCMessageUploadingStatus
case disableUploadingPhotoStatus
case disableUploadingFileStatus
case disableChoosingLocationStatus
case disableChoosingContactStatus
case disablePlayingGameStatus
case disableRecordingRoundVideoStatus
case disableUploadingRoundVideoStatus
case disableSpeakingInGroupCallStatus
case disableChoosingStickerStatus
case disableEmojiInteractionStatus
case disableEmojiAcknowledgementStatus
case disableMessageReadReceipt
/// When message read receipts are hidden (ghost): if true, replying to an incoming message marks it read on the server.
case ghostModeMarkReadOnReply
case disableStoryReadReceipt
case disableAllAds
case hideProxySponsor
case enableSavingProtectedContent
case forwardRestrictedAsCopy
case disableScreenshotDetection
case enableSavingSelfDestructingMessages
case disableSecretChatBlurOnScreenshot
case doubleBottomEnabled
case enableLocalPremium
case scrollToTopButtonEnabled
case fakeLocationEnabled
case enableVideoToCircleOrVoice
case userProfileNotes
case enableTelescope
// Font replacement (A-Font style)
case enableFontReplacement
case fontReplacementName
case fontReplacementBoldName
case fontReplacementFilePath
case fontReplacementBoldFilePath
case enableLocalMessageEditing
case disableCompactNumbers
case disableZalgoText
// Оформление
case unlimitedFavoriteStickers
// Запись времени в сети
case enableOnlineStatusRecording
case onlineStatusRecordingIntervalMinutes
case savedOnlineStatusByPeerId
case addMusicFromDeviceToProfile
case hideReactions
case pluginSystemEnabled
case installedPluginsJson
case chatExportEnabled
case profileCoverMediaPath
case profileCoverIsVideo
case emojiDownloaderEnabled
case feelRichEnabled
case feelRichStarsAmount
case giftIdEnabled
case fakeProfileEnabled
case fakeProfileTargetUserId
case fakeProfileFirstName
case fakeProfileLastName
case fakeProfileUsername
case fakeProfilePhone
case fakeProfileId
case fakeProfilePremium
case fakeProfileVerified
case fakeProfileScam
case fakeProfileFake
case fakeProfileSupport
case fakeProfileBot
case currentAccountPeerId
case customProfileGiftSlugs
case customProfileGiftShownSlugs
case pinnedCustomProfileGiftSlugs
case localProfileGiftStatusFileId
case hookInspectorEnabled
/// Square circle avatar rounding (GLEGram appearance).
case customAvatarRoundingEnabled
case avatarRoundingPercent
/// Title for self-chat (Saved / My notes): default | displayName | username | custom
case selfChatTitleMode
case selfChatTitleCustomText
/// Face blur in video messages (Vision framework).
case faceBlurInVideoMessages
/// Experimental: Puter-style voice conversion (see GLEGram Privacy). Processing not wired to send/calls in this build.
case voiceChangerEnabled
case puterVoiceChangerVoiceId
}
public enum DownloadSpeedBoostValues: String, CaseIterable {
case none
case medium
case maximum
}
public enum BottomTabStyleValues: String, CaseIterable {
case telegram
case ios
}
public enum AllChatsTitleLengthOverride: String, CaseIterable {
case none
case short
case long
}
public enum AllChatsFolderPositionOverride: String, CaseIterable {
case none
case last
case hidden
}
public enum MessageDoubleTapAction: String, CaseIterable {
case `default`
case none
case edit
}
public enum VideoPIPSwipeDirection: String, CaseIterable {
case up
case down
case none
}
public enum TranscriptionBackend: String, CaseIterable {
case `default`
case apple
}
public enum TranslationBackend: String, CaseIterable {
case `default`
case gtranslate
case system
// Make sure to update TranslationConfiguration
}
public enum PinnedMessageNotificationsSettings: String, CaseIterable {
case `default`
case silenced
case disabled
}
public enum MentionsAndRepliesNotificationsSettings: String, CaseIterable {
case `default`
case silenced
case disabled
}
public enum NYStyle: String, CaseIterable {
case `default`
case snow
case lightning
}
public static let defaultValues: [String: Any] = [
Keys.hidePhoneInSettings.rawValue: true,
Keys.showTabNames.rawValue: true,
Keys.startTelescopeWithRearCam.rawValue: false,
Keys.accountColorsSaturation.rawValue: 100,
Keys.uploadSpeedBoost.rawValue: false,
Keys.downloadSpeedBoost.rawValue: DownloadSpeedBoostValues.none.rawValue,
Keys.rememberLastFolder.rawValue: false,
Keys.bottomTabStyle.rawValue: BottomTabStyleValues.telegram.rawValue,
Keys.lastAccountFolders.rawValue: [:],
Keys.localDNSForProxyHost.rawValue: false,
Keys.sendLargePhotos.rawValue: false,
Keys.outgoingPhotoQuality.rawValue: 70,
Keys.storyStealthMode.rawValue: false,
Keys.canUseStealthMode.rawValue: true,
Keys.disableSwipeToRecordStory.rawValue: false,
Keys.quickTranslateButton.rawValue: false,
Keys.outgoingLanguageTranslation.rawValue: [:],
Keys.showRepostToStory.rawValue: true,
Keys.contextShowSelectFromUser.rawValue: true,
Keys.contextShowSaveToCloud.rawValue: true,
Keys.contextShowRestrict.rawValue: true,
// Keys.contextShowBan.rawValue: true,
Keys.contextShowHideForwardName.rawValue: true,
Keys.contextShowReport.rawValue: true,
Keys.contextShowReply.rawValue: true,
Keys.contextShowPin.rawValue: true,
Keys.contextShowSaveMedia.rawValue: true,
Keys.contextShowMessageReplies.rawValue: true,
Keys.contextShowJson.rawValue: false,
Keys.disableScrollToNextChannel.rawValue: false,
Keys.disableScrollToNextTopic.rawValue: false,
Keys.disableChatSwipeOptions.rawValue: false,
Keys.disableDeleteChatSwipeOption.rawValue: false,
Keys.disableGalleryCamera.rawValue: false,
Keys.disableGalleryCameraPreview.rawValue: false,
Keys.disableSendAsButton.rawValue: false,
Keys.disableSnapDeletionEffect.rawValue: false,
Keys.stickerSize.rawValue: 100,
Keys.stickerTimestamp.rawValue: true,
Keys.hideRecordingButton.rawValue: false,
Keys.hideTabBar.rawValue: false,
Keys.showDC.rawValue: false,
Keys.showCreationDate.rawValue: true,
Keys.showRegDate.rawValue: true,
Keys.regDateCache.rawValue: [:],
Keys.compactChatList.rawValue: false,
Keys.compactFolderNames.rawValue: false,
Keys.allChatsTitleLengthOverride.rawValue: AllChatsTitleLengthOverride.none.rawValue,
// Keys.allChatsFolderPositionOverride.rawValue: AllChatsFolderPositionOverride.none.rawValue
Keys.allChatsHidden.rawValue: false,
Keys.defaultEmojisFirst.rawValue: false,
Keys.messageDoubleTapActionOutgoing.rawValue: MessageDoubleTapAction.default.rawValue,
Keys.wideChannelPosts.rawValue: false,
Keys.forceEmojiTab.rawValue: false,
Keys.hideChannelBottomButton.rawValue: false,
Keys.secondsInMessages.rawValue: false,
Keys.forceSystemSharing.rawValue: false,
Keys.confirmCalls.rawValue: true,
Keys.videoPIPSwipeDirection.rawValue: VideoPIPSwipeDirection.up.rawValue,
Keys.messageFilterKeywords.rawValue: [],
Keys.inputToolbar.rawValue: false,
Keys.primaryUserId.rawValue: "",
Keys.dismissedSGSuggestions.rawValue: [],
Keys.duckyAppIconAvailable.rawValue: true,
Keys.transcriptionBackend.rawValue: TranscriptionBackend.default.rawValue,
Keys.translationBackend.rawValue: TranslationBackend.default.rawValue,
// Default app badge (GLEGram Dark Purple)
Keys.customAppBadge.rawValue: "SkyAppBadge",
Keys.canUseNY.rawValue: false,
Keys.nyStyle.rawValue: NYStyle.default.rawValue,
Keys.wideTabBar.rawValue: false,
Keys.tabBarSearchEnabled.rawValue: true,
Keys.showDeletedMessages.rawValue: true,
Keys.saveEditHistory.rawValue: true,
// Saved Deleted Messages defaults (AyuGram-style)
Keys.saveDeletedMessagesMedia.rawValue: true,
Keys.saveDeletedMessagesReactions.rawValue: true,
Keys.saveDeletedMessagesForBots.rawValue: true,
// Ghost Mode defaults
Keys.ghostModeMessageSendDelaySeconds.rawValue: 0,
Keys.disableOnlineStatus.rawValue: false,
Keys.disableTypingStatus.rawValue: false,
Keys.disableRecordingVideoStatus.rawValue: false,
Keys.disableUploadingVideoStatus.rawValue: false,
Keys.disableVCMessageRecordingStatus.rawValue: false,
Keys.disableVCMessageUploadingStatus.rawValue: false,
Keys.disableUploadingPhotoStatus.rawValue: false,
Keys.disableUploadingFileStatus.rawValue: false,
Keys.disableChoosingLocationStatus.rawValue: false,
Keys.disableChoosingContactStatus.rawValue: false,
Keys.disablePlayingGameStatus.rawValue: false,
Keys.disableRecordingRoundVideoStatus.rawValue: false,
Keys.disableUploadingRoundVideoStatus.rawValue: false,
Keys.disableSpeakingInGroupCallStatus.rawValue: false,
Keys.disableChoosingStickerStatus.rawValue: false,
Keys.disableEmojiInteractionStatus.rawValue: false,
Keys.disableEmojiAcknowledgementStatus.rawValue: false,
Keys.disableMessageReadReceipt.rawValue: false,
Keys.ghostModeMarkReadOnReply.rawValue: true,
Keys.disableStoryReadReceipt.rawValue: false,
Keys.disableAllAds.rawValue: false,
Keys.hideProxySponsor.rawValue: false,
Keys.enableSavingProtectedContent.rawValue: false,
Keys.disableScreenshotDetection.rawValue: false,
Keys.enableSavingSelfDestructingMessages.rawValue: false,
Keys.disableSecretChatBlurOnScreenshot.rawValue: false,
Keys.doubleBottomEnabled.rawValue: false,
Keys.enableLocalPremium.rawValue: false,
Keys.scrollToTopButtonEnabled.rawValue: true,
Keys.fakeLocationEnabled.rawValue: false,
Keys.enableVideoToCircleOrVoice.rawValue: false,
Keys.userProfileNotes.rawValue: [:],
Keys.enableTelescope.rawValue: false,
Keys.enableFontReplacement.rawValue: false,
Keys.fontReplacementName.rawValue: "",
Keys.fontReplacementBoldName.rawValue: "",
Keys.fontReplacementFilePath.rawValue: "",
Keys.fontReplacementBoldFilePath.rawValue: "",
Keys.enableLocalMessageEditing.rawValue: false,
Keys.disableCompactNumbers.rawValue: false,
Keys.disableZalgoText.rawValue: false,
Keys.unlimitedFavoriteStickers.rawValue: true,
Keys.enableOnlineStatusRecording.rawValue: false,
Keys.onlineStatusRecordingIntervalMinutes.rawValue: 5,
Keys.savedOnlineStatusByPeerId.rawValue: "{}",
Keys.addMusicFromDeviceToProfile.rawValue: false,
Keys.hideReactions.rawValue: false,
Keys.pluginSystemEnabled.rawValue: false,
Keys.installedPluginsJson.rawValue: "[]",
Keys.chatExportEnabled.rawValue: false,
Keys.profileCoverMediaPath.rawValue: "",
Keys.profileCoverIsVideo.rawValue: false,
Keys.emojiDownloaderEnabled.rawValue: false,
Keys.feelRichEnabled.rawValue: false,
Keys.feelRichStarsAmount.rawValue: "1000",
Keys.giftIdEnabled.rawValue: false,
Keys.fakeProfileEnabled.rawValue: false,
Keys.fakeProfileTargetUserId.rawValue: "",
Keys.fakeProfileFirstName.rawValue: "",
Keys.fakeProfileLastName.rawValue: "",
Keys.fakeProfileUsername.rawValue: "",
Keys.fakeProfilePhone.rawValue: "",
Keys.fakeProfileId.rawValue: "",
Keys.fakeProfilePremium.rawValue: false,
Keys.fakeProfileVerified.rawValue: false,
Keys.fakeProfileScam.rawValue: false,
Keys.fakeProfileFake.rawValue: false,
Keys.fakeProfileSupport.rawValue: false,
Keys.fakeProfileBot.rawValue: false,
Keys.currentAccountPeerId.rawValue: "",
Keys.customProfileGiftSlugs.rawValue: [],
Keys.customProfileGiftShownSlugs.rawValue: [],
Keys.pinnedCustomProfileGiftSlugs.rawValue: [],
Keys.localProfileGiftStatusFileId.rawValue: "",
Keys.hookInspectorEnabled.rawValue: false,
Keys.customAvatarRoundingEnabled.rawValue: false,
Keys.avatarRoundingPercent.rawValue: Int32(0),
Keys.selfChatTitleMode.rawValue: "default",
Keys.selfChatTitleCustomText.rawValue: "",
Keys.voiceChangerEnabled.rawValue: false,
Keys.puterVoiceChangerVoiceId.rawValue: "21m00Tcm4TlvDq8ikWAM"
]
public static let groupDefaultValues: [String: Any] = [
Keys.legacyNotificationsFix.rawValue: false,
Keys.pinnedMessageNotifications.rawValue: PinnedMessageNotificationsSettings.default.rawValue,
Keys.mentionsAndRepliesNotifications.rawValue: MentionsAndRepliesNotificationsSettings.default.rawValue,
Keys.status.rawValue: 1,
Keys.showRepostToStoryV2.rawValue: true,
]
@UserDefault(key: Keys.hidePhoneInSettings.rawValue)
public var hidePhoneInSettings: Bool
@UserDefault(key: Keys.showTabNames.rawValue)
public var showTabNames: Bool
@UserDefault(key: Keys.startTelescopeWithRearCam.rawValue)
public var startTelescopeWithRearCam: Bool
@UserDefault(key: Keys.accountColorsSaturation.rawValue)
public var accountColorsSaturation: Int32
@UserDefault(key: Keys.uploadSpeedBoost.rawValue)
public var uploadSpeedBoost: Bool
@UserDefault(key: Keys.downloadSpeedBoost.rawValue)
public var downloadSpeedBoost: String
@UserDefault(key: Keys.rememberLastFolder.rawValue)
public var rememberLastFolder: Bool
// Disabled while Telegram is migrating to Glass
// @UserDefault(key: Keys.bottomTabStyle.rawValue)
public var bottomTabStyle: String {
set {}
get {
return BottomTabStyleValues.ios.rawValue
}
}
public var lastAccountFolders = UserDefaultsBackedDictionary<String, Int32>(userDefaultsKey: Keys.lastAccountFolders.rawValue, threadSafe: false)
@UserDefault(key: Keys.localDNSForProxyHost.rawValue)
public var localDNSForProxyHost: Bool
@UserDefault(key: Keys.sendLargePhotos.rawValue)
public var sendLargePhotos: Bool
@UserDefault(key: Keys.outgoingPhotoQuality.rawValue)
public var outgoingPhotoQuality: Int32
@UserDefault(key: Keys.storyStealthMode.rawValue)
public var storyStealthMode: Bool
@UserDefault(key: Keys.canUseStealthMode.rawValue)
public var canUseStealthMode: Bool
@UserDefault(key: Keys.disableSwipeToRecordStory.rawValue)
public var disableSwipeToRecordStory: Bool
@UserDefault(key: Keys.quickTranslateButton.rawValue)
public var quickTranslateButton: Bool
public var outgoingLanguageTranslation = UserDefaultsBackedDictionary<String, String>(userDefaultsKey: Keys.outgoingLanguageTranslation.rawValue, threadSafe: false)
// @available(*, deprecated, message: "Use showRepostToStoryV2 instead")
@UserDefault(key: Keys.showRepostToStory.rawValue)
public var showRepostToStory: Bool
@UserDefault(key: Keys.showRepostToStoryV2.rawValue, userDefaults: UserDefaults(suiteName: APP_GROUP_IDENTIFIER) ?? .standard)
public var showRepostToStoryV2: Bool
@UserDefault(key: Keys.contextShowRestrict.rawValue)
public var contextShowRestrict: Bool
/*@UserDefault(key: Keys.contextShowBan.rawValue)
public var contextShowBan: Bool*/
@UserDefault(key: Keys.contextShowSelectFromUser.rawValue)
public var contextShowSelectFromUser: Bool
@UserDefault(key: Keys.contextShowSaveToCloud.rawValue)
public var contextShowSaveToCloud: Bool
@UserDefault(key: Keys.contextShowHideForwardName.rawValue)
public var contextShowHideForwardName: Bool
@UserDefault(key: Keys.contextShowReport.rawValue)
public var contextShowReport: Bool
@UserDefault(key: Keys.contextShowReply.rawValue)
public var contextShowReply: Bool
@UserDefault(key: Keys.contextShowPin.rawValue)
public var contextShowPin: Bool
@UserDefault(key: Keys.contextShowSaveMedia.rawValue)
public var contextShowSaveMedia: Bool
@UserDefault(key: Keys.contextShowMessageReplies.rawValue)
public var contextShowMessageReplies: Bool
@UserDefault(key: Keys.contextShowJson.rawValue)
public var contextShowJson: Bool
@UserDefault(key: Keys.disableScrollToNextChannel.rawValue)
public var disableScrollToNextChannel: Bool
@UserDefault(key: Keys.disableScrollToNextTopic.rawValue)
public var disableScrollToNextTopic: Bool
@UserDefault(key: Keys.disableChatSwipeOptions.rawValue)
public var disableChatSwipeOptions: Bool
@UserDefault(key: Keys.disableDeleteChatSwipeOption.rawValue)
public var disableDeleteChatSwipeOption: Bool
@UserDefault(key: Keys.disableGalleryCamera.rawValue)
public var disableGalleryCamera: Bool
@UserDefault(key: Keys.disableGalleryCameraPreview.rawValue)
public var disableGalleryCameraPreview: Bool
@UserDefault(key: Keys.disableSendAsButton.rawValue)
public var disableSendAsButton: Bool
@UserDefault(key: Keys.disableSnapDeletionEffect.rawValue)
public var disableSnapDeletionEffect: Bool
@UserDefault(key: Keys.stickerSize.rawValue)
public var stickerSize: Int32
@UserDefault(key: Keys.stickerTimestamp.rawValue)
public var stickerTimestamp: Bool
@UserDefault(key: Keys.hideRecordingButton.rawValue)
public var hideRecordingButton: Bool
@UserDefault(key: Keys.hideTabBar.rawValue)
public var hideTabBar: Bool
@UserDefault(key: Keys.showDC.rawValue)
public var showDC: Bool
@UserDefault(key: Keys.showCreationDate.rawValue)
public var showCreationDate: Bool
@UserDefault(key: Keys.showRegDate.rawValue)
public var showRegDate: Bool
public var regDateCache = UserDefaultsBackedDictionary<String, Data>(userDefaultsKey: Keys.regDateCache.rawValue, threadSafe: false)
@UserDefault(key: Keys.compactChatList.rawValue)
public var compactChatList: Bool
@UserDefault(key: Keys.compactFolderNames.rawValue)
public var compactFolderNames: Bool
@UserDefault(key: Keys.allChatsTitleLengthOverride.rawValue)
public var allChatsTitleLengthOverride: String
//
// @UserDefault(key: Keys.allChatsFolderPositionOverride.rawValue)
// public var allChatsFolderPositionOverride: String
@UserDefault(key: Keys.allChatsHidden.rawValue)
public var allChatsHidden: Bool
@UserDefault(key: Keys.defaultEmojisFirst.rawValue)
public var defaultEmojisFirst: Bool
@UserDefault(key: Keys.messageDoubleTapActionOutgoing.rawValue)
public var messageDoubleTapActionOutgoing: String
@UserDefault(key: Keys.wideChannelPosts.rawValue)
public var wideChannelPosts: Bool
@UserDefault(key: Keys.forceEmojiTab.rawValue)
public var forceEmojiTab: Bool
@UserDefault(key: Keys.forceBuiltInMic.rawValue)
public var forceBuiltInMic: Bool
@UserDefault(key: Keys.secondsInMessages.rawValue)
public var secondsInMessages: Bool
@UserDefault(key: Keys.hideChannelBottomButton.rawValue)
public var hideChannelBottomButton: Bool
@UserDefault(key: Keys.forceSystemSharing.rawValue)
public var forceSystemSharing: Bool
@UserDefault(key: Keys.confirmCalls.rawValue)
public var confirmCalls: Bool
@UserDefault(key: Keys.videoPIPSwipeDirection.rawValue)
public var videoPIPSwipeDirection: String
@UserDefault(key: Keys.legacyNotificationsFix.rawValue, userDefaults: UserDefaults(suiteName: APP_GROUP_IDENTIFIER) ?? .standard)
public var legacyNotificationsFix: Bool
@UserDefault(key: Keys.status.rawValue, userDefaults: UserDefaults(suiteName: APP_GROUP_IDENTIFIER) ?? .standard)
public var status: Int64
public var ephemeralStatus: Int64 = 1
@UserDefault(key: Keys.messageFilterKeywords.rawValue)
public var messageFilterKeywords: [String]
@UserDefault(key: Keys.inputToolbar.rawValue)
public var inputToolbar: Bool
@UserDefault(key: Keys.pinnedMessageNotifications.rawValue, userDefaults: UserDefaults(suiteName: APP_GROUP_IDENTIFIER) ?? .standard)
public var pinnedMessageNotifications: String
@UserDefault(key: Keys.mentionsAndRepliesNotifications.rawValue, userDefaults: UserDefaults(suiteName: APP_GROUP_IDENTIFIER) ?? .standard)
public var mentionsAndRepliesNotifications: String
@UserDefault(key: Keys.primaryUserId.rawValue)
public var primaryUserId: String
@UserDefault(key: Keys.dismissedSGSuggestions.rawValue)
public var dismissedSGSuggestions: [String]
@UserDefault(key: Keys.duckyAppIconAvailable.rawValue)
public var duckyAppIconAvailable: Bool
@UserDefault(key: Keys.transcriptionBackend.rawValue)
public var transcriptionBackend: String
@UserDefault(key: Keys.translationBackend.rawValue)
public var translationBackend: String
@UserDefault(key: Keys.customAppBadge.rawValue)
public var customAppBadge: String
@UserDefault(key: Keys.canUseNY.rawValue)
public var canUseNY: Bool
@UserDefault(key: Keys.nyStyle.rawValue)
public var nyStyle: String
@UserDefault(key: Keys.wideTabBar.rawValue)
public var wideTabBar: Bool
@UserDefault(key: Keys.tabBarSearchEnabled.rawValue)
public var tabBarSearchEnabled: Bool
@UserDefault(key: Keys.showDeletedMessages.rawValue)
public var showDeletedMessages: Bool
@UserDefault(key: Keys.saveEditHistory.rawValue)
public var saveEditHistory: Bool
// MARK: Saved Deleted Messages (AyuGram-style)
@UserDefault(key: Keys.saveDeletedMessagesMedia.rawValue)
public var saveDeletedMessagesMedia: Bool
@UserDefault(key: Keys.saveDeletedMessagesReactions.rawValue)
public var saveDeletedMessagesReactions: Bool
@UserDefault(key: Keys.saveDeletedMessagesForBots.rawValue)
public var saveDeletedMessagesForBots: Bool
// Ghost Mode settings
/// 0 = off, 12 / 30 / 45 = delay in seconds
@UserDefault(key: Keys.ghostModeMessageSendDelaySeconds.rawValue)
public var ghostModeMessageSendDelaySeconds: Int32
@UserDefault(key: Keys.disableOnlineStatus.rawValue)
public var disableOnlineStatus: Bool
@UserDefault(key: Keys.disableTypingStatus.rawValue)
public var disableTypingStatus: Bool
@UserDefault(key: Keys.disableRecordingVideoStatus.rawValue)
public var disableRecordingVideoStatus: Bool
@UserDefault(key: Keys.disableUploadingVideoStatus.rawValue)
public var disableUploadingVideoStatus: Bool
@UserDefault(key: Keys.disableVCMessageRecordingStatus.rawValue)
public var disableVCMessageRecordingStatus: Bool
@UserDefault(key: Keys.disableVCMessageUploadingStatus.rawValue)
public var disableVCMessageUploadingStatus: Bool
@UserDefault(key: Keys.disableUploadingPhotoStatus.rawValue)
public var disableUploadingPhotoStatus: Bool
@UserDefault(key: Keys.disableUploadingFileStatus.rawValue)
public var disableUploadingFileStatus: Bool
@UserDefault(key: Keys.disableChoosingLocationStatus.rawValue)
public var disableChoosingLocationStatus: Bool
@UserDefault(key: Keys.disableChoosingContactStatus.rawValue)
public var disableChoosingContactStatus: Bool
@UserDefault(key: Keys.disablePlayingGameStatus.rawValue)
public var disablePlayingGameStatus: Bool
@UserDefault(key: Keys.disableRecordingRoundVideoStatus.rawValue)
public var disableRecordingRoundVideoStatus: Bool
@UserDefault(key: Keys.disableUploadingRoundVideoStatus.rawValue)
public var disableUploadingRoundVideoStatus: Bool
@UserDefault(key: Keys.disableSpeakingInGroupCallStatus.rawValue)
public var disableSpeakingInGroupCallStatus: Bool
@UserDefault(key: Keys.disableChoosingStickerStatus.rawValue)
public var disableChoosingStickerStatus: Bool
@UserDefault(key: Keys.disableEmojiInteractionStatus.rawValue)
public var disableEmojiInteractionStatus: Bool
@UserDefault(key: Keys.disableEmojiAcknowledgementStatus.rawValue)
public var disableEmojiAcknowledgementStatus: Bool
@UserDefault(key: Keys.disableMessageReadReceipt.rawValue)
public var disableMessageReadReceipt: Bool
/// If `true` (default), replying to an incoming message still sends a read receipt while message receipts are disabled. If `false`, reply does not mark as read (context menu «Read» is unchanged).
@UserDefault(key: Keys.ghostModeMarkReadOnReply.rawValue)
public var ghostModeMarkReadOnReply: Bool
@UserDefault(key: Keys.disableStoryReadReceipt.rawValue)
public var disableStoryReadReceipt: Bool
@UserDefault(key: Keys.disableAllAds.rawValue)
public var disableAllAds: Bool
@UserDefault(key: Keys.hideProxySponsor.rawValue)
public var hideProxySponsor: Bool
@UserDefault(key: Keys.enableSavingProtectedContent.rawValue)
public var enableSavingProtectedContent: Bool
@UserDefault(key: Keys.forwardRestrictedAsCopy.rawValue)
public var forwardRestrictedAsCopy: Bool
@UserDefault(key: Keys.enableSavingSelfDestructingMessages.rawValue)
public var enableSavingSelfDestructingMessages: Bool
@UserDefault(key: Keys.disableSecretChatBlurOnScreenshot.rawValue)
public var disableSecretChatBlurOnScreenshot: Bool
@UserDefault(key: Keys.doubleBottomEnabled.rawValue)
public var doubleBottomEnabled: Bool
@UserDefault(key: Keys.enableLocalPremium.rawValue)
public var enableLocalPremium: Bool
@UserDefault(key: Keys.voiceChangerEnabled.rawValue)
public var voiceChangerEnabled: Bool
@UserDefault(key: Keys.puterVoiceChangerVoiceId.rawValue)
public var puterVoiceChangerVoiceId: String
@UserDefault(key: Keys.disableScreenshotDetection.rawValue)
public var disableScreenshotDetection: Bool
@UserDefault(key: Keys.scrollToTopButtonEnabled.rawValue)
public var scrollToTopButtonEnabled: Bool
@UserDefault(key: Keys.fakeLocationEnabled.rawValue)
public var fakeLocationEnabled: Bool
public var fakeLatitude: Double {
get { SGFileStore.shared.double(forKey: "fakeLatitude", default: 0.0) }
set { SGFileStore.shared.set(newValue, forKey: "fakeLatitude") }
}
public var fakeLongitude: Double {
get { SGFileStore.shared.double(forKey: "fakeLongitude", default: 0.0) }
set { SGFileStore.shared.set(newValue, forKey: "fakeLongitude") }
}
@UserDefault(key: Keys.enableVideoToCircleOrVoice.rawValue)
public var enableVideoToCircleOrVoice: Bool
public var userProfileNotes = UserDefaultsBackedDictionary<String, String>(userDefaultsKey: Keys.userProfileNotes.rawValue, threadSafe: false)
@UserDefault(key: Keys.enableTelescope.rawValue)
public var enableTelescope: Bool
/// Font replacement (A-Font style): enable, main font name, bold font name, size multiplier (100 = 1.0)
@UserDefault(key: Keys.enableFontReplacement.rawValue)
public var enableFontReplacement: Bool
@UserDefault(key: Keys.fontReplacementName.rawValue)
public var fontReplacementName: String
@UserDefault(key: Keys.fontReplacementBoldName.rawValue)
public var fontReplacementBoldName: String
/// Persistent path to copied main font file (so it survives app restart)
@UserDefault(key: Keys.fontReplacementFilePath.rawValue)
public var fontReplacementFilePath: String
/// Persistent path to copied bold font file
@UserDefault(key: Keys.fontReplacementBoldFilePath.rawValue)
public var fontReplacementBoldFilePath: String
public var fontReplacementSizeMultiplier: Int32 {
get { SGFileStore.shared.int32(forKey: "fontReplacementSizeMultiplier", default: 100) }
set { SGFileStore.shared.set(Int(newValue), forKey: "fontReplacementSizeMultiplier") }
}
@UserDefault(key: Keys.enableLocalMessageEditing.rawValue)
public var enableLocalMessageEditing: Bool
@UserDefault(key: Keys.disableCompactNumbers.rawValue)
public var disableCompactNumbers: Bool
@UserDefault(key: Keys.disableZalgoText.rawValue)
public var disableZalgoText: Bool
@UserDefault(key: Keys.unlimitedFavoriteStickers.rawValue)
public var unlimitedFavoriteStickers: Bool
@UserDefault(key: Keys.enableOnlineStatusRecording.rawValue)
public var enableOnlineStatusRecording: Bool
@UserDefault(key: Keys.onlineStatusRecordingIntervalMinutes.rawValue)
public var onlineStatusRecordingIntervalMinutes: Int32
@UserDefault(key: Keys.savedOnlineStatusByPeerId.rawValue)
public var savedOnlineStatusByPeerId: String
@UserDefault(key: Keys.addMusicFromDeviceToProfile.rawValue)
public var addMusicFromDeviceToProfile: Bool
@UserDefault(key: Keys.hideReactions.rawValue)
public var hideReactions: Bool
@UserDefault(key: Keys.pluginSystemEnabled.rawValue)
public var pluginSystemEnabled: Bool
@UserDefault(key: Keys.installedPluginsJson.rawValue)
public var installedPluginsJson: String
/// True if the installed-plugins list has at least one enabled entry with a `.js` path (used to wire plugin UI/hooks even when master `pluginSystemEnabled` is off).
public var hasEnabledJavaScriptPluginInstalled: Bool {
guard let data = installedPluginsJson.data(using: .utf8),
let arr = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else {
return false
}
for obj in arr {
guard (obj["enabled"] as? Bool) == true, let path = obj["path"] as? String else { continue }
if (path as NSString).pathExtension.lowercased() == "js" { return true }
}
return false
}
/// Включён мастер «Plugin system» или в списке есть активный `.js` нужно вешать `PluginHost` в чате и хуки. Учитывает `GLEGramFeatures.pluginsEnabled`.
public var pluginsJavaScriptBridgeActive: Bool {
guard GLEGramFeatures.pluginsEnabled else { return false }
return pluginSystemEnabled || hasEnabledJavaScriptPluginInstalled
}
@UserDefault(key: Keys.chatExportEnabled.rawValue)
public var chatExportEnabled: Bool
@UserDefault(key: Keys.profileCoverMediaPath.rawValue)
public var profileCoverMediaPath: String
@UserDefault(key: Keys.profileCoverIsVideo.rawValue)
public var profileCoverIsVideo: Bool
@UserDefault(key: Keys.emojiDownloaderEnabled.rawValue)
public var emojiDownloaderEnabled: Bool
@UserDefault(key: Keys.feelRichEnabled.rawValue)
public var feelRichEnabled: Bool
@UserDefault(key: Keys.feelRichStarsAmount.rawValue)
public var feelRichStarsAmount: String
@UserDefault(key: Keys.giftIdEnabled.rawValue)
public var giftIdEnabled: Bool
@UserDefault(key: Keys.fakeProfileEnabled.rawValue)
public var fakeProfileEnabled: Bool
@UserDefault(key: Keys.fakeProfileTargetUserId.rawValue)
public var fakeProfileTargetUserId: String
@UserDefault(key: Keys.fakeProfileFirstName.rawValue)
public var fakeProfileFirstName: String
@UserDefault(key: Keys.fakeProfileLastName.rawValue)
public var fakeProfileLastName: String
@UserDefault(key: Keys.fakeProfileUsername.rawValue)
public var fakeProfileUsername: String
@UserDefault(key: Keys.fakeProfilePhone.rawValue)
public var fakeProfilePhone: String
@UserDefault(key: Keys.fakeProfileId.rawValue)
public var fakeProfileId: String
@UserDefault(key: Keys.fakeProfilePremium.rawValue)
public var fakeProfilePremium: Bool
@UserDefault(key: Keys.fakeProfileVerified.rawValue)
public var fakeProfileVerified: Bool
@UserDefault(key: Keys.fakeProfileScam.rawValue)
public var fakeProfileScam: Bool
@UserDefault(key: Keys.fakeProfileFake.rawValue)
public var fakeProfileFake: Bool
@UserDefault(key: Keys.fakeProfileSupport.rawValue)
public var fakeProfileSupport: Bool
@UserDefault(key: Keys.fakeProfileBot.rawValue)
public var fakeProfileBot: Bool
@UserDefault(key: Keys.currentAccountPeerId.rawValue)
public var currentAccountPeerId: String
@UserDefault(key: Keys.customProfileGiftSlugs.rawValue)
public var customProfileGiftSlugs: [String]
/// Slugs of custom gifts that are shown on profile (worn). Persisted locally so "Show/Hide" state doesn't reset.
@UserDefault(key: Keys.customProfileGiftShownSlugs.rawValue)
public var customProfileGiftShownSlugs: [String]
@UserDefault(key: Keys.pinnedCustomProfileGiftSlugs.rawValue)
public var pinnedCustomProfileGiftSlugs: [String]
/// When set, show this fileId as emoji status on my profile (so gift status doesn't disappear).
@UserDefault(key: Keys.localProfileGiftStatusFileId.rawValue)
public var localProfileGiftStatusFileId: String
@UserDefault(key: Keys.hookInspectorEnabled.rawValue)
public var hookInspectorEnabled: Bool
@UserDefault(key: Keys.faceBlurInVideoMessages.rawValue)
public var faceBlurInVideoMessages: Bool
@UserDefault(key: Keys.customAvatarRoundingEnabled.rawValue)
public var customAvatarRoundingEnabled: Bool
@UserDefault(key: Keys.avatarRoundingPercent.rawValue)
public var avatarRoundingPercent: Int32
@UserDefault(key: Keys.selfChatTitleMode.rawValue)
public var selfChatTitleMode: String
@UserDefault(key: Keys.selfChatTitleCustomText.rawValue)
public var selfChatTitleCustomText: String
/// Whether read receipts should be blocked for a specific peer (per-peer ghost mode).
public func shouldBlockReadReceiptFor(peerIdNamespace: Int32, peerIdId: Int64) -> Bool {
return false
}
/// Whether removed channels/user chats should be kept accessible.
public var keepRemovedChannels: Bool {
return false
}
/// Whether a specific channel was removed (for keep-removed-channels feature).
public func isChannelRemoved(_ peerIdValue: Int64) -> Bool {
return false
}
/// Whether a specific user chat was removed (for keep-removed-channels feature).
public func isUserChatRemoved(_ peerIdValue: Int64) -> Bool {
return false
}
/// Whether fake profile overlay should apply for this peer id (current account or target user).
public func shouldApplyFakeProfile(peerId: Int64) -> Bool {
guard fakeProfileEnabled else { return false }
let target: String = fakeProfileTargetUserId.isEmpty ? currentAccountPeerId : fakeProfileTargetUserId
guard let targetNum = Int64(target) else { return false }
return peerId == targetNum
}
/// Display value for first name when fake profile is active.
public func displayFirstName(peerId: Int64, real: String?) -> String {
shouldApplyFakeProfile(peerId: peerId) && !fakeProfileFirstName.isEmpty ? fakeProfileFirstName : (real ?? "")
}
/// Display value for last name when fake profile is active.
public func displayLastName(peerId: Int64, real: String?) -> String {
shouldApplyFakeProfile(peerId: peerId) && !fakeProfileLastName.isEmpty ? fakeProfileLastName : (real ?? "")
}
/// Display value for username (without @) when fake profile is active.
public func displayUsername(peerId: Int64, real: String?) -> String {
shouldApplyFakeProfile(peerId: peerId) && !fakeProfileUsername.isEmpty ? fakeProfileUsername : (real ?? "")
}
/// Display value for phone when fake profile is active.
public func displayPhone(peerId: Int64, real: String?) -> String {
shouldApplyFakeProfile(peerId: peerId) && !fakeProfilePhone.isEmpty ? fakeProfilePhone : (real ?? "")
}
/// Display value for user id string when fake profile is active.
public func displayId(peerId: Int64, real: String?) -> String {
shouldApplyFakeProfile(peerId: peerId) && !fakeProfileId.isEmpty ? fakeProfileId : (real ?? "")
}
/// Saved "last seen" timestamps per peer (for online status recording). Key: peerId as Int64, value: timestamp.
public var savedOnlineStatusByPeerIdDict: [Int64: Int32] {
get {
guard let data = savedOnlineStatusByPeerId.data(using: .utf8),
let dict = try? JSONDecoder().decode([String: Int32].self, from: data) else {
return [:]
}
var result: [Int64: Int32] = [:]
for (k, v) in dict where Int64(k) != nil {
result[Int64(k)!] = v
}
return result
}
set {
let dict = Dictionary(uniqueKeysWithValues: newValue.map { ("\($0.key)", $0.value) })
if let data = try? JSONEncoder().encode(dict),
let string = String(data: data, encoding: .utf8) {
savedOnlineStatusByPeerId = string
synchronizeShared()
}
}
}
public func getSavedOnlineStatusTimestamp(peerId: Int64) -> Int32? {
return savedOnlineStatusByPeerIdDict[peerId]
}
public static let onlineStatusTimestampDidChangeNotification = Notification.Name("SGOnlineStatusTimestampDidChange")
public func setSavedOnlineStatusTimestamp(peerId: Int64, timestamp: Int32) {
var dict = savedOnlineStatusByPeerIdDict
dict[peerId] = timestamp
savedOnlineStatusByPeerIdDict = dict
DispatchQueue.main.async {
NotificationCenter.default.post(name: SGSimpleSettings.onlineStatusTimestampDidChangeNotification, object: nil, userInfo: ["peerId": peerId])
}
}
/// Strip Zalgo / combining characters from string (for display when disableZalgoText is on).
public static func stripZalgo(_ string: String) -> String {
return string.filter { char in
!char.unicodeScalars.contains(where: { (scalar: Unicode.Scalar) in
let cat = scalar.properties.generalCategory
return cat == .nonspacingMark || cat == .spacingMark || cat == .enclosingMark
})
}
}
}
extension SGSimpleSettings {
public var isStealthModeEnabled: Bool {
return storyStealthMode && canUseStealthMode
}
public static func makeOutgoingLanguageTranslationKey(accountId: Int64, peerId: Int64) -> String {
return "\(accountId):\(peerId)"
}
}
extension SGSimpleSettings {
public var translationBackendEnum: SGSimpleSettings.TranslationBackend {
return TranslationBackend(rawValue: translationBackend) ?? .default
}
public var transcriptionBackendEnum: SGSimpleSettings.TranscriptionBackend {
return TranscriptionBackend(rawValue: transcriptionBackend) ?? .default
}
}
extension SGSimpleSettings {
public var isNYEnabled: Bool {
return canUseNY && NYStyle(rawValue: nyStyle) != .default
}
/// Check if a peer should be treated as premium, considering local premium setting
/// - Parameters:
/// - peerId: The peer ID to check
/// - accountPeerId: The current account's peer ID
/// - isPremium: The actual premium status from Telegram
/// - Returns: True if the peer should be treated as premium (either has real premium or has local premium enabled for current user)
public func isPremium(peerId: Int64, accountPeerId: Int64, isPremium: Bool) -> Bool {
if isPremium {
return true
}
// Local premium only applies to the current user
if self.enableLocalPremium && peerId == accountPeerId {
return true
}
// Fake profile: show premium badge for the substituted profile when enabled
if self.shouldApplyFakeProfile(peerId: peerId) && self.fakeProfilePremium {
return true
}
return false
}
}
public func getSGDownloadPartSize(_ default: Int64, fileSize: Int64?) -> Int64 {
let currentDownloadSetting = SGSimpleSettings.shared.downloadSpeedBoost
// Increasing chunk size for small files make it worse in terms of overall download performance
let smallFileSizeThreshold = 1 * 1024 * 1024 // 1 MB
switch (currentDownloadSetting) {
case SGSimpleSettings.DownloadSpeedBoostValues.medium.rawValue:
if let fileSize, fileSize <= smallFileSizeThreshold {
return `default`
}
return 512 * 1024
case SGSimpleSettings.DownloadSpeedBoostValues.maximum.rawValue:
if let fileSize, fileSize <= smallFileSizeThreshold {
return `default`
}
return 1024 * 1024
default:
return `default`
}
}
public func getSGMaxPendingParts(_ default: Int) -> Int {
let currentDownloadSetting = SGSimpleSettings.shared.downloadSpeedBoost
switch (currentDownloadSetting) {
case SGSimpleSettings.DownloadSpeedBoostValues.medium.rawValue:
return 8
case SGSimpleSettings.DownloadSpeedBoostValues.maximum.rawValue:
return 12
default:
return `default`
}
}
public func sgUseShortAllChatsTitle(_ default: Bool) -> Bool {
let currentOverride = SGSimpleSettings.shared.allChatsTitleLengthOverride
switch (currentOverride) {
case SGSimpleSettings.AllChatsTitleLengthOverride.short.rawValue:
return true
case SGSimpleSettings.AllChatsTitleLengthOverride.long.rawValue:
return false
default:
return `default`
}
}
// MARK: - GLEGram settings backup (export / import JSON)
public extension SGSimpleSettings {
/// Must match `@UserDefault(..., userDefaults: UserDefaults(suiteName: APP_GROUP_IDENTIFIER))` properties.
static let keysUsingAppGroupUserDefaults: Set<String> = [
Keys.showRepostToStoryV2.rawValue,
Keys.legacyNotificationsFix.rawValue,
Keys.status.rawValue,
Keys.pinnedMessageNotifications.rawValue,
Keys.mentionsAndRepliesNotifications.rawValue
]
/// Writes a JSON file to the temp directory; use with document picker export.
static func exportGLEGramSettingsJSONFile() throws -> URL {
var entries: [String: Any] = [:]
let standard = UserDefaults.standard
let group = UserDefaults(suiteName: APP_GROUP_IDENTIFIER)
for key in Keys.allCases {
let k = key.rawValue
if let object = standard.object(forKey: k) {
entries[k] = object
} else if let object = group?.object(forKey: k) {
entries[k] = object
}
}
let root: [String: Any] = [
"format": "gleg_settings",
"version": 1,
"entries": entries
]
let data = try JSONSerialization.data(withJSONObject: root, options: [.prettyPrinted, .sortedKeys])
let url = FileManager.default.temporaryDirectory.appendingPathComponent("GLEGram_settings.json")
try data.write(to: url, options: .atomic)
return url
}
/// Applies keys from exported JSON; returns number of keys written.
@discardableResult
static func importGLEGramSettingsJSON(data: Data) throws -> Int {
let json = try JSONSerialization.jsonObject(with: data)
let entries: [String: Any]
if let root = json as? [String: Any] {
if let e = root["entries"] as? [String: Any] {
entries = e
} else if root["format"] == nil, root.keys.contains(where: { Keys(rawValue: $0) != nil }) {
entries = root
} else {
throw NSError(domain: "SGSimpleSettings", code: 2, userInfo: [NSLocalizedDescriptionKey: "Invalid GLEGram settings file"])
}
} else {
throw NSError(domain: "SGSimpleSettings", code: 2, userInfo: [NSLocalizedDescriptionKey: "Invalid GLEGram settings file"])
}
let standard = UserDefaults.standard
let group = UserDefaults(suiteName: APP_GROUP_IDENTIFIER)
var count = 0
for (key, value) in entries {
guard Keys(rawValue: key) != nil else { continue }
guard isValidUserDefaultsJSONImportValue(value) else { continue }
if keysUsingAppGroupUserDefaults.contains(key) {
group?.set(value, forKey: key)
} else {
standard.set(value, forKey: key)
}
count += 1
}
standard.synchronize()
group?.synchronize()
return count
}
private static func isValidUserDefaultsJSONImportValue(_ value: Any) -> Bool {
if value is String || value is NSNumber || value is Bool {
return true
}
if let arr = value as? [Any] {
return arr.allSatisfy { isValidUserDefaultsJSONImportValue($0) }
}
if let dict = value as? [String: Any] {
return dict.values.allSatisfy { isValidUserDefaultsJSONImportValue($0) }
}
return false
}
}
public extension Notification.Name {
/// Posted when Hide Proxy Sponsor is toggled so the chat list can refresh.
static let sgHideProxySponsorDidChange = Notification.Name("SGHideProxySponsorDidChange")
/// Posted when GLEGram avatar rounding toggle or slider changes.
static let sgAvatarRoundingSettingsDidChange = Notification.Name("SGAvatarRoundingSettingsDidChange")
/// Posted when main chats list title mode or custom text changes (root «Чаты» / Chats).
static let sgSelfChatTitleSettingsDidChange = Notification.Name("SGSelfChatTitleSettingsDidChange")
/// Posted when profile full-screen color or related GLEGram appearance toggles change (refresh Peer Info).
static let sgPeerInfoAppearanceSettingsDidChange = Notification.Name("SGPeerInfoAppearanceSettingsDidChange")
/// Posted when «Local Telegram Premium» is toggled so `AccountContext.isPremium` can refresh.
static let sgEnableLocalPremiumDidChange = Notification.Name("SGEnableLocalPremiumDidChange")
/// Posted when a custom badge image finishes caching so title views can refresh.
static let sgBadgeImageDidCache = Notification.Name("SGBadgeImageDidCache")
}
/// How to show the title on the main chats tab (above stories), not in Saved/self-chat.
public enum SelfChatTitleMode: String, CaseIterable {
case `default`
case displayName
case username
}
public extension SGSimpleSettings {
var selfChatTitleModeValue: SelfChatTitleMode {
get {
let raw = self.selfChatTitleMode
if raw == "custom" {
return .default
}
return SelfChatTitleMode(rawValue: raw) ?? .default
}
set {
self.selfChatTitleMode = newValue.rawValue
}
}
}
```
### `Swiftgram/SGSettingsUI/Sources/PluginMetadata.swift`
```swift
// MARK: Swiftgram Plugin metadata (exteraGram-compatible .plugin file format)
import Foundation
/// Metadata parsed from a .plugin file (exteraGram plugin format).
public struct PluginMetadata: Codable, Equatable {
public let id: String
public let name: String
public let description: String
public let version: String
public let author: String
/// Icon reference e.g. "ApplicationEmoji/141" or "glePlugins/1".
public let iconRef: String?
public let minVersion: String?
/// If true, plugin modifies profile display (Fake Profilestyle).
public let hasUserDisplay: Bool
/// Permissions requested by the plugin (e.g. ["ui", "chat", "network", "compose", "settings"]).
public let permissions: [String]?
public init(id: String, name: String, description: String, version: String, author: String, iconRef: String? = nil, minVersion: String? = nil, hasUserDisplay: Bool = false, permissions: [String]? = nil) {
self.id = id
self.name = name
self.description = description
self.version = version
self.author = author
self.iconRef = iconRef
self.minVersion = minVersion
self.hasUserDisplay = hasUserDisplay
self.permissions = permissions
}
public init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
id = try c.decode(String.self, forKey: .id)
name = try c.decode(String.self, forKey: .name)
description = try c.decode(String.self, forKey: .description)
version = try c.decode(String.self, forKey: .version)
author = try c.decode(String.self, forKey: .author)
iconRef = try c.decodeIfPresent(String.self, forKey: .iconRef)
minVersion = try c.decodeIfPresent(String.self, forKey: .minVersion)
hasUserDisplay = try c.decodeIfPresent(Bool.self, forKey: .hasUserDisplay) ?? false
permissions = try c.decodeIfPresent([String].self, forKey: .permissions)
}
}
/// Installed plugin info (stored in settings).
public struct PluginInfo: Codable, Equatable {
public var metadata: PluginMetadata
public var path: String
public var enabled: Bool
public var hasSettings: Bool
public init(metadata: PluginMetadata, path: String, enabled: Bool, hasSettings: Bool) {
self.metadata = metadata
self.path = path
self.enabled = enabled
self.hasSettings = hasSettings
}
}
/// Parses exteraGram-style metadata from .plugin file content (Python script with __name__, __description__, etc.).
public enum PluginMetadataParser {
private static let namePattern = #"__name__\s*=\s*["']([^"']+)["']"#
private static let descriptionPattern = #"__description__\s*=\s*["']([^"']+)["']"#
private static let versionPattern = #"__version__\s*=\s*["']([^"']+)["']"#
private static let authorPattern = #"__author__\s*=\s*["']([^"']+)["']"#
private static let idPattern = #"__id__\s*=\s*["']([^"']+)["']"#
private static let iconPattern = #"__icon__\s*=\s*["']([^"']+)["']"#
private static let minVersionPattern = #"__min_version__\s*=\s*["']([^"']+)["']"#
private static let createSettingsPattern = #"def\s+create_settings\s*\("#
/// Some plugins set __settings__ = True (e.g. panic_passcode_pro).
private static let settingsFlagPattern = #"__settings__\s*=\s*True"#
/// Plugins that modify profile display (Fake Profilestyle) set __user_display__ = True.
private static let userDisplayPattern = #"__user_display__\s*=\s*True"#
public static func parse(content: String) -> PluginMetadata? {
guard let name = firstMatch(in: content, pattern: namePattern),
let id = firstMatch(in: content, pattern: idPattern) else {
return nil
}
let description = firstMatch(in: content, pattern: descriptionPattern) ?? ""
let version = firstMatch(in: content, pattern: versionPattern) ?? "1.0"
let author = firstMatch(in: content, pattern: authorPattern) ?? ""
let iconRef = firstMatch(in: content, pattern: iconPattern)
let minVersion = firstMatch(in: content, pattern: minVersionPattern)
let hasUserDisplay = content.range(of: userDisplayPattern, options: .regularExpression) != nil
return PluginMetadata(id: id, name: name, description: description, version: version, author: author, iconRef: iconRef, minVersion: minVersion, hasUserDisplay: hasUserDisplay)
}
public static func hasCreateSettings(content: String) -> Bool {
content.range(of: createSettingsPattern, options: .regularExpression) != nil
|| content.range(of: settingsFlagPattern, options: .regularExpression) != nil
}
/// Parses metadata from a JavaScript plugin file (GLEGram JS plugin format).
/// Expects a global object: Plugin = { id?, name, author?, version?, description? } (single or double quotes).
public static func parseJavaScript(content: String) -> PluginMetadata? {
let idPattern = #"(?:["']id["']|\bid)\s*:\s*["']([^"']*)["']"#
let namePattern = #"(?:["']name["']|\bname)\s*:\s*["']([^"']+)["']"#
let authorPattern = #"(?:["']author["']|\bauthor)\s*:\s*["']([^"']*)["']"#
let versionPattern = #"(?:["']version["']|\bversion)\s*:\s*["']([^"']*)["']"#
let descriptionPattern = #"(?:["']description["']|\bdescription)\s*:\s*["']([^"']*)["']"#
let name = firstMatch(in: content, pattern: namePattern)
let id = firstMatch(in: content, pattern: idPattern)
?? name.map { $0.lowercased().replacingOccurrences(of: " ", with: "-").filter { $0.isLetter || $0.isNumber || $0 == "-" } }
guard let id = id, !id.isEmpty, let name = name else { return nil }
let author = firstMatch(in: content, pattern: authorPattern) ?? ""
let version = firstMatch(in: content, pattern: versionPattern) ?? "1.0"
let description = firstMatch(in: content, pattern: descriptionPattern) ?? ""
// Parse permissions: ["ui", "chat", "network"]
let permissionsPattern = #"(?:["']permissions["']|\bpermissions)\s*:\s*\[([^\]]*)\]"#
var permissions: [String]?
if let permStr = firstMatch(in: content, pattern: permissionsPattern) {
let items = permStr.components(separatedBy: ",").compactMap { item -> String? in
let trimmed = item.trimmingCharacters(in: .whitespaces).trimmingCharacters(in: CharacterSet(charactersIn: "\"'"))
return trimmed.isEmpty ? nil : trimmed
}
if !items.isEmpty { permissions = items }
}
return PluginMetadata(id: id, name: name, description: description, version: version, author: author, iconRef: nil, minVersion: nil, hasUserDisplay: false, permissions: permissions)
}
private static func firstMatch(in string: String, pattern: String) -> String? {
guard let regex = try? NSRegularExpression(pattern: pattern),
let match = regex.firstMatch(in: string, range: NSRange(string.startIndex..., in: string)),
let range = Range(match.range(at: 1), in: string) else {
return nil
}
return String(string[range])
}
}
```
### `Swiftgram/SGSettingsUI/Sources/PluginBridge.swift`
```swift
// MARK: Swiftgram Plugin bridge (Swift Python runtime for exteraGram .plugin files)
//
// This module provides a bridge to run or query exteraGram-style .plugin files (Python).
// - Default: metadata and settings detection via regex (PluginMetadataParser), works on iOS/macOS.
// - Optional: when PythonKit (https://github.com/pvieito/PythonKit) is available, use
// PythonPluginRuntime to execute plugin code in a sandbox and read metadata from Python.
//
// swift-bridge (https://github.com/chinedufn/swift-bridge) is for RustSwift; for SwiftPython
// we use PythonKit. This protocol allows swapping implementations (regex-only vs PythonKit).
import Foundation
/// Runtime used to parse or execute .plugin file content (exteraGram Python format).
public protocol PluginRuntime: Sendable {
/// Parses plugin metadata (__name__, __id__, __description__, etc.) from file content.
func parseMetadata(content: String) -> PluginMetadata?
/// Returns true if the plugin defines create_settings or __settings__ = True.
func hasCreateSettings(content: String) -> Bool
}
/// Default implementation using regex-based parsing (no Python required). Works on iOS and macOS.
public final class DefaultPluginRuntime: PluginRuntime, @unchecked Sendable {
public static let shared = DefaultPluginRuntime()
public init() {}
public func parseMetadata(content: String) -> PluginMetadata? {
PluginMetadataParser.parse(content: content)
}
public func hasCreateSettings(content: String) -> Bool {
PluginMetadataParser.hasCreateSettings(content: content)
}
}
/// Current runtime used by the app. Set to a PythonKit-based runtime when Python is available.
public var currentPluginRuntime: PluginRuntime = DefaultPluginRuntime.shared
```
### `Swiftgram/SGSettingsUI/Sources/PluginBridgePythonKit.swift`
```swift
// MARK: Swiftgram Plugin bridge via PythonKit (Swift Python)
//
// Uses PythonKit (https://github.com/pvieito/PythonKit) when available.
// exteraGram plugins import Android/Java (base_plugin, org.telegram.messenger, etc.);
// on iOS/macOS those are unavailable, so we use regex parsing by default. When PythonKit
// is linked, you can implement full execution with builtins.exec(code, globals, locals)
// and stub modules (base_plugin, java, ui, ...) so the script runs and exposes __name__, etc.
//
// To enable PythonKit: add as SPM dependency or vendored; on iOS embed a Python framework.
import Foundation
#if canImport(PythonKit)
import PythonKit
/// Runtime that can use Python to parse/run plugin content when PythonKit is available.
/// Currently delegates to regex parser; replace with exec()-based implementation when
/// stubs for base_plugin/java/android are ready.
public final class PythonPluginRuntime: PluginRuntime, @unchecked Sendable {
public static let shared = PythonPluginRuntime()
private init() {}
public func parseMetadata(content: String) -> PluginMetadata? {
// Optional: use Python builtins.exec(content, globals, locals) with stubbed
// base_plugin, java, ui, etc., then read __name__, __id__, ... from globals.
// For now use regex so it works without a full Python stub environment.
return PluginMetadataParser.parse(content: content)
}
public func hasCreateSettings(content: String) -> Bool {
PluginMetadataParser.hasCreateSettings(content: content)
}
}
#else
// When PythonKit is not linked, PythonPluginRuntime is not compiled; app uses DefaultPluginRuntime.
#endif
```
### `Swiftgram/SGSettingsUI/Sources/ItemListPluginRowItem.swift`
```swift
// MARK: Swiftgram Plugin row item (like Active sites: icon, name, author, description, switch)
import Foundation
import UIKit
import Display
import AsyncDisplayKit
import SwiftSignalKit
import TelegramPresentationData
import ItemListUI
import PresentationDataUtils
import AccountContext
import AppBundle
/// One row per plugin: icon, name, author, description; switch on the right (like Active sites).
final class ItemListPluginRowItem: ListViewItem, ItemListItem {
let presentationData: ItemListPresentationData
let plugin: PluginInfo
let icon: UIImage?
let sectionId: ItemListSectionId
let toggle: (Bool) -> Void
let action: (() -> Void)?
init(presentationData: ItemListPresentationData, plugin: PluginInfo, icon: UIImage?, sectionId: ItemListSectionId, toggle: @escaping (Bool) -> Void, action: (() -> Void)? = nil) {
self.presentationData = presentationData
self.plugin = plugin
self.icon = icon
self.sectionId = sectionId
self.toggle = toggle
self.action = action
}
func nodeConfiguredForParams(async: @escaping (@escaping () -> Void) -> Void, params: ListViewItemLayoutParams, synchronousLoads: Bool, previousItem: ListViewItem?, nextItem: ListViewItem?, completion: @escaping (ListViewItemNode, @escaping () -> (Signal<Void, NoError>?, (ListViewItemApply) -> Void)) -> Void) {
async {
let node = ItemListPluginRowItemNode()
let (layout, apply) = node.asyncLayout()(self, params, itemListNeighbors(item: self, topItem: previousItem as? ItemListItem, bottomItem: nextItem as? ItemListItem))
node.contentSize = layout.contentSize
node.insets = layout.insets
Queue.mainQueue().async {
completion(node, { return (nil, { _ in apply(false) }) })
}
}
}
func updateNode(async: @escaping (@escaping () -> Void) -> Void, node: @escaping () -> ListViewItemNode, params: ListViewItemLayoutParams, previousItem: ListViewItem?, nextItem: ListViewItem?, animation: ListViewItemUpdateAnimation, completion: @escaping (ListViewItemNodeLayout, @escaping (ListViewItemApply) -> Void) -> Void) {
Queue.mainQueue().async {
if let nodeValue = node() as? ItemListPluginRowItemNode {
let makeLayout = nodeValue.asyncLayout()
async {
let (layout, apply) = makeLayout(self, params, itemListNeighbors(item: self, topItem: previousItem as? ItemListItem, bottomItem: nextItem as? ItemListItem))
Queue.mainQueue().async {
completion(layout, { _ in apply(animation.isAnimated) })
}
}
}
}
}
var selectable: Bool { action != nil }
func selected(listView: ListView) {
listView.clearHighlightAnimated(true)
action?()
}
}
private let leftInsetNoIcon: CGFloat = 16.0
private let iconSize: CGFloat = 30.0
private let leftInsetWithIcon: CGFloat = 16.0 + iconSize + 13.0
private let switchWidth: CGFloat = 51.0
private let switchRightInset: CGFloat = 15.0
final class ItemListPluginRowItemNode: ListViewItemNode {
private let backgroundNode: ASDisplayNode
private let topStripeNode: ASDisplayNode
private let bottomStripeNode: ASDisplayNode
private let highlightedBackgroundNode: ASDisplayNode
private let maskNode: ASImageNode
private let iconNode: ASImageNode
private let titleNode: TextNode
private let authorNode: TextNode
private let descriptionNode: TextNode
private var switchNode: ASDisplayNode?
private var switchView: UISwitch?
private var layoutParams: (ItemListPluginRowItem, ListViewItemLayoutParams, ItemListNeighbors)?
init() {
self.backgroundNode = ASDisplayNode()
self.backgroundNode.isLayerBacked = true
self.topStripeNode = ASDisplayNode()
self.topStripeNode.isLayerBacked = true
self.bottomStripeNode = ASDisplayNode()
self.bottomStripeNode.isLayerBacked = true
self.maskNode = ASImageNode()
self.maskNode.isUserInteractionEnabled = false
self.iconNode = ASImageNode()
self.iconNode.contentMode = .scaleAspectFit
self.iconNode.cornerRadius = 7.0
self.iconNode.clipsToBounds = true
self.iconNode.isLayerBacked = true
self.titleNode = TextNode()
self.titleNode.isUserInteractionEnabled = false
self.titleNode.contentsScale = UIScreen.main.scale
self.authorNode = TextNode()
self.authorNode.isUserInteractionEnabled = false
self.authorNode.contentsScale = UIScreen.main.scale
self.descriptionNode = TextNode()
self.descriptionNode.isUserInteractionEnabled = false
self.descriptionNode.contentsScale = UIScreen.main.scale
self.highlightedBackgroundNode = ASDisplayNode()
self.highlightedBackgroundNode.isLayerBacked = true
super.init(layerBacked: false, rotated: false, seeThrough: false)
addSubnode(self.backgroundNode)
addSubnode(self.topStripeNode)
addSubnode(self.bottomStripeNode)
addSubnode(self.maskNode)
addSubnode(self.iconNode)
addSubnode(self.titleNode)
addSubnode(self.authorNode)
addSubnode(self.descriptionNode)
}
func asyncLayout() -> (ItemListPluginRowItem, ListViewItemLayoutParams, ItemListNeighbors) -> (ListViewItemNodeLayout, (Bool) -> Void) {
let makeTitle = TextNode.asyncLayout(self.titleNode)
let makeAuthor = TextNode.asyncLayout(self.authorNode)
let makeDescription = TextNode.asyncLayout(self.descriptionNode)
return { item, params, neighbors in
let titleFont = Font.medium(floor(item.presentationData.fontSize.itemListBaseFontSize * 16.0 / 17.0))
let textFont = Font.regular(floor(item.presentationData.fontSize.itemListBaseFontSize * 14.0 / 17.0))
let leftInset = leftInsetWithIcon + params.leftInset
let rightInset = params.rightInset + switchWidth + switchRightInset
let textWidth = params.width - leftInset - rightInset - 8.0
let meta = item.plugin.metadata
let titleAttr = NSAttributedString(string: meta.name, font: titleFont, textColor: item.presentationData.theme.list.itemPrimaryTextColor)
let lang = item.presentationData.strings.baseLanguageCode
let versionAuthor = (lang == "ru" ? "Версия " : "Version ") + "\(meta.version) · \(meta.author)"
let authorAttr = NSAttributedString(string: versionAuthor, font: textFont, textColor: item.presentationData.theme.list.itemSecondaryTextColor)
let descAttr = NSAttributedString(string: meta.description, font: textFont, textColor: item.presentationData.theme.list.itemPrimaryTextColor)
let (titleLayout, titleApply) = makeTitle(TextNodeLayoutArguments(attributedString: titleAttr, backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: textWidth, height: .greatestFiniteMagnitude), alignment: .natural, cutout: nil, insets: .zero))
let (authorLayout, authorApply) = makeAuthor(TextNodeLayoutArguments(attributedString: authorAttr, backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: textWidth, height: .greatestFiniteMagnitude), alignment: .natural, cutout: nil, insets: .zero))
let (descLayout, descApply) = makeDescription(TextNodeLayoutArguments(attributedString: descAttr, backgroundColor: nil, maximumNumberOfLines: 2, truncationType: .end, constrainedSize: CGSize(width: textWidth, height: .greatestFiniteMagnitude), alignment: .natural, cutout: nil, insets: .zero))
let verticalInset: CGFloat = 4.0
let rowHeight: CGFloat = verticalInset * 2 + 10 + titleLayout.size.height + 4 + authorLayout.size.height + 4 + descLayout.size.height
let contentHeight = max(75.0, rowHeight)
let insets = itemListNeighborsGroupedInsets(neighbors, params)
let layout = ListViewItemNodeLayout(contentSize: CGSize(width: params.width, height: contentHeight), insets: insets)
let layoutSize = layout.size
let separatorHeight = UIScreenPixel
return (layout, { [weak self] animated in
guard let self = self else { return }
self.layoutParams = (item, params, neighbors)
let theme = item.presentationData.theme
self.topStripeNode.backgroundColor = theme.list.itemBlocksSeparatorColor
self.bottomStripeNode.backgroundColor = theme.list.itemBlocksSeparatorColor
self.backgroundNode.backgroundColor = theme.list.itemBlocksBackgroundColor
self.highlightedBackgroundNode.backgroundColor = theme.list.itemHighlightedBackgroundColor
self.iconNode.image = item.icon
let _ = titleApply()
let _ = authorApply()
let _ = descApply()
if self.switchView == nil {
let sw = UISwitch()
sw.addTarget(self, action: #selector(self.switchChanged(_:)), for: .valueChanged)
self.switchView = sw
self.switchNode = ASDisplayNode(viewBlock: { sw })
self.addSubnode(self.switchNode!)
}
self.switchView?.isOn = item.plugin.enabled
self.switchView?.isUserInteractionEnabled = true
let hasCorners = itemListHasRoundedBlockLayout(params)
var hasTopCorners = false
var hasBottomCorners = false
switch neighbors.top {
case .sameSection(false): self.topStripeNode.isHidden = true
default: hasTopCorners = true; self.topStripeNode.isHidden = hasCorners
}
let bottomStripeInset: CGFloat
switch neighbors.bottom {
case .sameSection(false): bottomStripeInset = leftInsetWithIcon + params.leftInset
default: bottomStripeInset = 0; hasBottomCorners = true; self.bottomStripeNode.isHidden = hasCorners
}
self.maskNode.image = hasCorners ? PresentationResourcesItemList.cornersImage(theme, top: hasTopCorners, bottom: hasBottomCorners, glass: false) : nil
self.backgroundNode.frame = CGRect(origin: CGPoint(x: 0, y: -min(insets.top, separatorHeight)), size: CGSize(width: params.width, height: contentHeight + min(insets.top, separatorHeight) + min(insets.bottom, separatorHeight)))
self.maskNode.frame = self.backgroundNode.frame.insetBy(dx: params.leftInset, dy: 0)
self.topStripeNode.frame = CGRect(x: 0, y: -min(insets.top, separatorHeight), width: layoutSize.width, height: separatorHeight)
self.bottomStripeNode.frame = CGRect(x: bottomStripeInset, y: contentHeight, width: layoutSize.width - bottomStripeInset - params.rightInset, height: separatorHeight)
self.iconNode.frame = CGRect(x: params.leftInset + 16, y: verticalInset + 10, width: iconSize, height: iconSize)
let textX = params.leftInset + 16 + iconSize + 13
self.titleNode.frame = CGRect(origin: CGPoint(x: textX, y: verticalInset + 10), size: titleLayout.size)
self.authorNode.frame = CGRect(origin: CGPoint(x: textX, y: verticalInset + 10 + titleLayout.size.height + 4), size: authorLayout.size)
self.descriptionNode.frame = CGRect(origin: CGPoint(x: textX, y: verticalInset + 10 + titleLayout.size.height + 4 + authorLayout.size.height + 4), size: descLayout.size)
let switchSize = self.switchView?.bounds.size ?? CGSize(width: switchWidth, height: 31)
self.switchNode?.frame = CGRect(x: params.width - params.rightInset - switchWidth - switchRightInset, y: floor((contentHeight - switchSize.height) / 2.0), width: switchWidth, height: switchSize.height)
self.highlightedBackgroundNode.frame = self.backgroundNode.frame
})
}
}
@objc private func switchChanged(_ sender: UISwitch) {
if let item = self.layoutParams?.0 {
item.toggle(sender.isOn)
}
}
override func setHighlighted(_ highlighted: Bool, at point: CGPoint, animated: Bool) {
super.setHighlighted(highlighted, at: point, animated: animated)
if highlighted {
self.highlightedBackgroundNode.alpha = 1
if self.highlightedBackgroundNode.supernode == nil {
self.insertSubnode(self.highlightedBackgroundNode, aboveSubnode: self.backgroundNode)
}
} else {
if animated {
self.highlightedBackgroundNode.layer.animateAlpha(from: self.highlightedBackgroundNode.alpha, to: 0, duration: 0.25)
}
self.highlightedBackgroundNode.alpha = 0
}
}
}
```
### `Swiftgram/SGSettingsUI/Sources/PluginListController.swift`
```swift
// MARK: Swiftgram Plugin list (like Active sites: icon, name, author, description, switch; Settings below)
import Foundation
import UIKit
import ObjectiveC
import UniformTypeIdentifiers
import Display
import SwiftSignalKit
import TelegramPresentationData
import ItemListUI
import PresentationDataUtils
import AccountContext
import SGSimpleSettings
import AppBundle
private var documentPickerDelegateKey: UInt8 = 0
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()
}
}
// Master switch + Add plugin (.js) + installed plugins list.
private enum PluginListEntry: ItemListNodeEntry {
case pluginSystemSwitch(id: Int, title: String, subtitle: String?, value: Bool)
case addJsAction(id: Int, text: String)
case createJsAction(id: Int, text: String)
case listHeader(id: Int, text: String)
case pluginRow(id: Int, plugin: PluginInfo)
case pluginSettings(id: Int, pluginId: String, text: String)
case pluginEditCode(id: Int, pluginId: String, text: String)
case pluginDelete(id: Int, pluginId: String, text: String)
case emptyNotice(id: Int, text: String)
var id: Int { stableId }
var section: ItemListSectionId {
switch self {
case .pluginSystemSwitch, .addJsAction, .createJsAction: return 0
case .listHeader, .pluginRow, .pluginSettings, .pluginEditCode, .pluginDelete, .emptyNotice: return 1
}
}
var stableId: Int {
switch self {
case .pluginSystemSwitch(let id, _, _, _), .addJsAction(let id, _), .createJsAction(let id, _), .listHeader(let id, _), .pluginRow(let id, _), .pluginSettings(let id, _, _), .pluginEditCode(let id, _, _), .pluginDelete(let id, _, _), .emptyNotice(let id, _): return id
}
}
static func < (lhs: PluginListEntry, rhs: PluginListEntry) -> Bool { lhs.stableId < rhs.stableId }
static func == (lhs: PluginListEntry, rhs: PluginListEntry) -> Bool {
switch (lhs, rhs) {
case let (.pluginSystemSwitch(a, t1, s1, v1), .pluginSystemSwitch(b, t2, s2, v2)):
return a == b && t1 == t2 && s1 == s2 && v1 == v2
case let (.addJsAction(a, t1), .addJsAction(b, t2)): return a == b && t1 == t2
case let (.createJsAction(a, t1), .createJsAction(b, t2)): return a == b && t1 == t2
case let (.listHeader(a, t1), .listHeader(b, t2)): return a == b && t1 == t2
case let (.pluginRow(a, p1), .pluginRow(b, p2)): return a == b && p1.metadata.id == p2.metadata.id && p1.enabled == p2.enabled
case let (.pluginSettings(a, id1, t1), .pluginSettings(b, id2, t2)),
let (.pluginEditCode(a, id1, t1), .pluginEditCode(b, id2, t2)),
let (.pluginDelete(a, id1, t1), .pluginDelete(b, id2, t2)): return a == b && id1 == id2 && t1 == t2
case let (.emptyNotice(a, t1), .emptyNotice(b, t2)): return a == b && t1 == t2
default: return false
}
}
func item(presentationData: ItemListPresentationData, arguments: Any) -> ListViewItem {
let args = arguments as! PluginListArguments
switch self {
case .pluginSystemSwitch(_, let title, let subtitle, let value):
return ItemListSwitchItem(
presentationData: presentationData,
title: title,
text: subtitle,
value: value,
sectionId: self.section,
style: .blocks,
updated: { args.setPluginSystemEnabled($0) },
action: nil
)
case .addJsAction(_, let text):
return ItemListActionItem(presentationData: presentationData, title: text, kind: .generic, alignment: .natural, sectionId: self.section, style: .blocks, action: { args.addJsPlugin() })
case .createJsAction(_, let text):
return ItemListActionItem(presentationData: presentationData, title: text, kind: .generic, alignment: .natural, sectionId: self.section, style: .blocks, action: { args.createJsPlugin() })
case .listHeader(_, let text):
return ItemListSectionHeaderItem(presentationData: presentationData, text: text, sectionId: self.section)
case .pluginRow(_, let plugin):
let icon = args.iconResolver(plugin.metadata.iconRef)
return ItemListPluginRowItem(presentationData: presentationData, plugin: plugin, icon: icon, sectionId: self.section, toggle: { value in args.toggle(plugin.metadata.id, value) }, action: nil)
case .pluginSettings(_, let pluginId, let text):
return ItemListDisclosureItem(presentationData: presentationData, title: text, label: "", sectionId: self.section, style: .blocks, action: { args.openSettings(pluginId) })
case .pluginEditCode(_, let pluginId, let text):
return ItemListActionItem(presentationData: presentationData, title: text, kind: .generic, alignment: .natural, sectionId: self.section, style: .blocks, action: { args.editPluginCode(pluginId) })
case .pluginDelete(_, let pluginId, let text):
return ItemListActionItem(presentationData: presentationData, title: text, kind: .destructive, alignment: .natural, sectionId: self.section, style: .blocks, action: { args.deletePlugin(pluginId) })
case .emptyNotice(_, let text):
return ItemListTextItem(presentationData: presentationData, text: .plain(text), sectionId: self.section)
}
}
}
private final class PluginListArguments {
let setPluginSystemEnabled: (Bool) -> Void
let toggle: (String, Bool) -> Void
let openSettings: (String) -> Void
let deletePlugin: (String) -> Void
let addJsPlugin: () -> Void
let createJsPlugin: () -> Void
let editPluginCode: (String) -> Void
let iconResolver: (String?) -> UIImage?
init(setPluginSystemEnabled: @escaping (Bool) -> Void, toggle: @escaping (String, Bool) -> Void, openSettings: @escaping (String) -> Void, deletePlugin: @escaping (String) -> Void, addJsPlugin: @escaping () -> Void, createJsPlugin: @escaping () -> Void, editPluginCode: @escaping (String) -> Void, iconResolver: @escaping (String?) -> UIImage?) {
self.setPluginSystemEnabled = setPluginSystemEnabled
self.toggle = toggle
self.openSettings = openSettings
self.deletePlugin = deletePlugin
self.addJsPlugin = addJsPlugin
self.createJsPlugin = createJsPlugin
self.editPluginCode = editPluginCode
self.iconResolver = iconResolver
}
}
private func pluginListEntries(presentationData: PresentationData, plugins: [PluginInfo]) -> [PluginListEntry] {
let lang = presentationData.strings.baseLanguageCode
var entries: [PluginListEntry] = []
var id = 0
let systemTitle = lang == "ru" ? "Система плагинов" : "Plugin system"
let systemSubtitle = lang == "ru"
? "Включает JS-плагины и хуки (отправка сообщений, меню и т.д.)."
: "Enables JS plugins and hooks (outgoing messages, menus, etc.)."
entries.append(.pluginSystemSwitch(id: id, title: systemTitle, subtitle: systemSubtitle, value: SGSimpleSettings.shared.pluginSystemEnabled))
id += 1
entries.append(.addJsAction(id: id, text: lang == "ru" ? "Добавить плагин (.js)" : "Add plugin (.js)"))
id += 1
entries.append(.createJsAction(id: id, text: lang == "ru" ? "Редактор кода" : "Code Editor"))
id += 1
entries.append(.listHeader(id: id, text: lang == "ru" ? "УСТАНОВЛЕННЫЕ ПЛАГИНЫ" : "INSTALLED PLUGINS"))
id += 1
for plugin in plugins {
let meta = plugin.metadata
entries.append(.pluginRow(id: id, plugin: plugin))
id += 1
if plugin.hasSettings {
entries.append(.pluginSettings(id: id, pluginId: meta.id, text: lang == "ru" ? "Настройки" : "Settings"))
id += 1
}
entries.append(.pluginEditCode(id: id, pluginId: meta.id, text: lang == "ru" ? "Редактировать код" : "Edit code"))
id += 1
entries.append(.pluginDelete(id: id, pluginId: meta.id, text: lang == "ru" ? "Удалить" : "Remove"))
id += 1
}
if plugins.isEmpty {
entries.append(.emptyNotice(id: id, text: lang == "ru" ? "Нет установленных плагинов." : "No installed plugins."))
}
return entries
}
public func PluginListController(context: AccountContext, onPluginsChanged: @escaping () -> Void) -> ViewController {
let reloadPromise = ValuePromise(true, ignoreRepeated: false)
var presentJsPicker: (() -> Void)?
var pushControllerImpl: ((ViewController) -> Void)?
var backAction: (() -> Void)?
let arguments = PluginListArguments(
setPluginSystemEnabled: { value in
SGSimpleSettings.shared.pluginSystemEnabled = value
if value {
PluginRunner.shared.ensureLoaded()
} else {
PluginRunner.shared.shutdown()
}
reloadPromise.set(true)
onPluginsChanged()
},
toggle: { pluginId, value in
var plugins = loadInstalledPlugins()
if let idx = plugins.firstIndex(where: { $0.metadata.id == pluginId }) {
let isJs = (plugins[idx].path as NSString).pathExtension.lowercased() == "js"
if isJs && !value {
PluginRunner.shared.unload(pluginId: pluginId)
}
plugins[idx].enabled = value
saveInstalledPlugins(plugins)
if isJs && value {
PluginRunner.shared.ensureLoaded()
}
reloadPromise.set(true)
onPluginsChanged()
}
},
openSettings: { pluginId in
let plugins = loadInstalledPlugins()
guard let plugin = plugins.first(where: { $0.metadata.id == pluginId }) else { return }
let settingsController = PluginSettingsController(context: context, plugin: plugin, onSave: {
reloadPromise.set(true)
onPluginsChanged()
})
pushControllerImpl?(settingsController)
},
deletePlugin: { pluginId in
PluginRunner.shared.unload(pluginId: pluginId)
var plugins = loadInstalledPlugins()
plugins.removeAll { $0.metadata.id == pluginId }
saveInstalledPlugins(plugins)
reloadPromise.set(true)
onPluginsChanged()
},
addJsPlugin: { presentJsPicker?() },
createJsPlugin: {
let editor = pluginCodeEditorController(context: context, existingPlugin: nil, initialCode: "", onSave: { _ in
reloadPromise.set(true)
onPluginsChanged()
})
pushControllerImpl?(editor)
},
editPluginCode: { pluginId in
let plugins = loadInstalledPlugins()
guard let plugin = plugins.first(where: { $0.metadata.id == pluginId }) else { return }
let code = (try? String(contentsOfFile: plugin.path, encoding: .utf8)) ?? ""
let editor = pluginCodeEditorController(context: context, existingPlugin: plugin, initialCode: code, onSave: { _ in
reloadPromise.set(true)
onPluginsChanged()
})
pushControllerImpl?(editor)
},
iconResolver: { iconRef in
guard let ref = iconRef, !ref.isEmpty else { return nil }
if let img = UIImage(bundleImageName: ref) { return img }
return UIImage(bundleImageName: "glePlugins/1")
}
)
let signal = combineLatest(reloadPromise.get(), context.sharedContext.presentationData)
|> map { _, presentationData -> (ItemListControllerState, (ItemListNodeState, PluginListArguments)) in
let plugins = loadInstalledPlugins()
let controllerState = ItemListControllerState(
presentationData: ItemListPresentationData(presentationData),
title: .text(presentationData.strings.baseLanguageCode == "ru" ? "Плагины" : "Plugins"),
leftNavigationButton: ItemListNavigationButton(content: .text(presentationData.strings.Common_Back), style: .regular, enabled: true, action: { backAction?() }),
rightNavigationButton: nil,
backNavigationButton: ItemListBackButton(title: presentationData.strings.Common_Back)
)
let entries = pluginListEntries(presentationData: presentationData, plugins: plugins)
let listState = ItemListNodeState(presentationData: ItemListPresentationData(presentationData), entries: entries, style: .blocks, ensureVisibleItemTag: nil, initialScrollToItem: nil)
return (controllerState, (listState, arguments))
}
let controller = ItemListController(context: context, state: signal)
backAction = { [weak controller] in controller?.dismiss() }
presentJsPicker = { [weak controller] in
guard let controller = controller else { return }
let picker: UIDocumentPickerViewController
if #available(iOS 14.0, *) {
let jsType = UTType(filenameExtension: "js") ?? .plainText
picker = UIDocumentPickerViewController(forOpeningContentTypes: [jsType], asCopy: true)
} else {
picker = UIDocumentPickerViewController(documentTypes: ["public.javascript", "public.plain-text"], in: .import)
}
let delegate = PluginDocumentPickerDelegate(
context: context,
onPick: { url in
_ = url.startAccessingSecurityScopedResource()
defer { url.stopAccessingSecurityScopedResource() }
let content = try? String(contentsOf: url, encoding: .utf8)
let fileName = url.lastPathComponent
let metadata: PluginMetadata
if let content = content, let parsed = PluginMetadataParser.parseJavaScript(content: content) {
metadata = parsed
} else {
let id = (fileName as NSString).deletingPathExtension
let name = id.isEmpty ? fileName : id
metadata = PluginMetadata(id: id.isEmpty ? "plugin_\(UUID().uuidString.prefix(8))" : id, name: name, description: "", version: "1.0", author: "", iconRef: nil, minVersion: nil, hasUserDisplay: false)
}
let fileManager = FileManager.default
guard let supportURL = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { return }
let pluginsDir = supportURL.appendingPathComponent("Plugins", isDirectory: true)
try? fileManager.createDirectory(at: pluginsDir, withIntermediateDirectories: true)
let destURL = pluginsDir.appendingPathComponent("\(metadata.id).js")
try? fileManager.removeItem(at: destURL)
try? fileManager.copyItem(at: url, to: destURL)
var plugins = loadInstalledPlugins()
plugins.append(PluginInfo(metadata: metadata, path: destURL.path, enabled: true, hasSettings: false))
saveInstalledPlugins(plugins)
PluginRunner.shared.ensureLoaded()
reloadPromise.set(true)
onPluginsChanged()
}
)
picker.delegate = delegate
objc_setAssociatedObject(picker, &documentPickerDelegateKey, delegate, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
controller.present(picker, animated: true)
}
pushControllerImpl = { [weak controller] vc in controller?.push(vc) }
return controller
}
private final class PluginDocumentPickerDelegate: NSObject, UIDocumentPickerDelegate {
let context: AccountContext
let onPick: (URL) -> Void
init(context: AccountContext, onPick: @escaping (URL) -> Void) {
self.context = context
self.onPick = onPick
}
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
guard let url = urls.first else { return }
onPick(url)
}
}
```
### `Swiftgram/SGSettingsUI/Sources/PluginCodeEditorController.swift`
```swift
// MARK: GLEGram Plugin code editor (create/edit JS plugins inline)
import Foundation
import UIKit
import Display
import SwiftSignalKit
import TelegramPresentationData
import ItemListUI
import PresentationDataUtils
import AccountContext
import SGSimpleSettings
// MARK: - State
private final class PluginCodeEditorStateHolder {
var name: String
var code: String
init(name: String, code: String) {
self.name = name
self.code = code
}
}
private struct PluginCodeEditorState: Equatable {
var name: String
var code: String
}
// MARK: - Entries
private enum PluginCodeEditorEntry: ItemListNodeEntry {
case nameInput(id: Int, text: String, placeholder: String)
case codeInput(id: Int, text: String, placeholder: String)
case notice(id: Int, text: String)
var section: ItemListSectionId {
switch self {
case .nameInput: return 0
case .codeInput: return 1
case .notice: return 2
}
}
var stableId: Int {
switch self {
case .nameInput(let id, _, _): return id
case .codeInput(let id, _, _): return id
case .notice(let id, _): return id
}
}
static func == (lhs: PluginCodeEditorEntry, rhs: PluginCodeEditorEntry) -> Bool {
switch (lhs, rhs) {
case let (.nameInput(a, t1, p1), .nameInput(b, t2, p2)): return a == b && t1 == t2 && p1 == p2
case let (.codeInput(a, t1, p1), .codeInput(b, t2, p2)): return a == b && t1 == t2 && p1 == p2
case let (.notice(a, t1), .notice(b, t2)): return a == b && t1 == t2
default: return false
}
}
static func < (lhs: PluginCodeEditorEntry, rhs: PluginCodeEditorEntry) -> Bool {
lhs.stableId < rhs.stableId
}
func item(presentationData: ItemListPresentationData, arguments: Any) -> ListViewItem {
let args = arguments as! PluginCodeEditorArguments
switch self {
case .nameInput(_, let text, let placeholder):
return ItemListSingleLineInputItem(
presentationData: presentationData,
title: NSAttributedString(),
text: text,
placeholder: placeholder,
sectionId: section,
textUpdated: { newText in args.updatedName(newText) },
action: {}
)
case .codeInput(_, let text, let placeholder):
return ItemListMultilineInputItem(
presentationData: presentationData,
text: text,
placeholder: placeholder,
maxLength: nil,
sectionId: section,
style: .blocks,
textUpdated: { newText in args.updatedCode(newText) },
updatedFocus: nil,
tag: nil,
action: nil,
inlineAction: nil
)
case .notice(_, let text):
return ItemListTextItem(presentationData: presentationData, text: .plain(text), sectionId: section)
}
}
}
// MARK: - Arguments
private final class PluginCodeEditorArguments {
var updatedName: (String) -> Void = { _ in }
var updatedCode: (String) -> Void = { _ in }
}
private final class PluginCodeEditorNavActions {
var cancel: (() -> Void)?
var done: (() -> Void)?
}
// MARK: - Entries builder
private func pluginCodeEditorEntries(state: PluginCodeEditorState, presentationData: PresentationData) -> [PluginCodeEditorEntry] {
let lang = presentationData.strings.baseLanguageCode
var entries: [PluginCodeEditorEntry] = []
entries.append(.nameInput(id: 0, text: state.name, placeholder: lang == "ru" ? "Имя плагина" : "Plugin name"))
entries.append(.codeInput(id: 1, text: state.code, placeholder: lang == "ru" ? "JavaScript код..." : "JavaScript code..."))
let noticeText = lang == "ru"
? "Используйте GLEGram.ui, GLEGram.chat, GLEGram.compose, GLEGram.messageActions, GLEGram.intercept, GLEGram.network, GLEGram.settings, GLEGram.events API."
: "Use GLEGram.ui, GLEGram.chat, GLEGram.compose, GLEGram.messageActions, GLEGram.intercept, GLEGram.network, GLEGram.settings, GLEGram.events API."
entries.append(.notice(id: 2, text: noticeText))
return entries
}
// MARK: - Controller
public func pluginCodeEditorController(context: AccountContext, existingPlugin: PluginInfo?, initialCode: String, onSave: @escaping (PluginInfo) -> Void) -> ViewController {
let initialName = existingPlugin?.metadata.name ?? ""
let stateHolder = PluginCodeEditorStateHolder(name: initialName, code: initialCode)
let navActions = PluginCodeEditorNavActions()
let statePromise = ValuePromise(PluginCodeEditorState(name: initialName, code: initialCode), ignoreRepeated: true)
let arguments = PluginCodeEditorArguments()
arguments.updatedName = { newName in
stateHolder.name = newName
statePromise.set(PluginCodeEditorState(name: newName, code: stateHolder.code))
}
arguments.updatedCode = { newCode in
stateHolder.code = newCode
statePromise.set(PluginCodeEditorState(name: stateHolder.name, code: newCode))
}
let signal = combineLatest(context.sharedContext.presentationData, statePromise.get())
|> map { presentationData, state -> (ItemListControllerState, (ItemListNodeState, PluginCodeEditorArguments)) in
let lang = presentationData.strings.baseLanguageCode
let title = existingPlugin != nil
? (lang == "ru" ? "Редактор" : "Editor")
: (lang == "ru" ? "Новый плагин" : "New Plugin")
let controllerState = ItemListControllerState(
presentationData: ItemListPresentationData(presentationData),
title: .text(title),
leftNavigationButton: ItemListNavigationButton(content: .text(presentationData.strings.Common_Cancel), style: .regular, enabled: true, action: { navActions.cancel?() }),
rightNavigationButton: ItemListNavigationButton(content: .text(lang == "ru" ? "Сохранить" : "Save"), style: .bold, enabled: !state.code.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, action: { navActions.done?() }),
backNavigationButton: ItemListBackButton(title: presentationData.strings.Common_Back)
)
let entries = pluginCodeEditorEntries(state: state, presentationData: presentationData)
let listState = ItemListNodeState(
presentationData: ItemListPresentationData(presentationData),
entries: entries,
style: .blocks,
ensureVisibleItemTag: nil,
initialScrollToItem: nil
)
return (controllerState, (listState, arguments))
}
let controller = ItemListController(context: context, state: signal)
navActions.cancel = { [weak controller] in
controller?.dismiss()
}
navActions.done = { [weak controller] in
let code = stateHolder.code.trimmingCharacters(in: .whitespacesAndNewlines)
guard !code.isEmpty else { return }
// Parse metadata from code
var metadata: PluginMetadata
if let parsed = PluginMetadataParser.parseJavaScript(content: code) {
metadata = parsed
} else {
let name = stateHolder.name.trimmingCharacters(in: .whitespacesAndNewlines)
let safeName = name.isEmpty ? "Untitled Plugin" : name
let safeId = existingPlugin?.metadata.id ?? safeName.lowercased()
.replacingOccurrences(of: " ", with: "-")
.filter { $0.isLetter || $0.isNumber || $0 == "-" }
let id = safeId.isEmpty ? "plugin-\(UUID().uuidString.prefix(8))" : safeId
metadata = PluginMetadata(id: id, name: safeName, description: "", version: "1.0", author: "")
}
// If editing, keep the same ID
if let existing = existingPlugin {
metadata = PluginMetadata(
id: existing.metadata.id,
name: metadata.name,
description: metadata.description,
version: metadata.version,
author: metadata.author,
iconRef: metadata.iconRef,
minVersion: metadata.minVersion,
hasUserDisplay: metadata.hasUserDisplay,
permissions: metadata.permissions
)
}
// Write file
let fileManager = FileManager.default
guard let supportURL = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { return }
let pluginsDir = supportURL.appendingPathComponent("Plugins", isDirectory: true)
try? fileManager.createDirectory(at: pluginsDir, withIntermediateDirectories: true)
let destURL = pluginsDir.appendingPathComponent("\(metadata.id).js")
try? code.write(to: destURL, atomically: true, encoding: .utf8)
// Unload old version if editing
if existingPlugin != nil {
PluginRunner.shared.unload(pluginId: metadata.id)
}
// Update installed list
let pluginInfo = PluginInfo(metadata: metadata, path: destURL.path, enabled: true, hasSettings: false)
var plugins: [PluginInfo]
if let data = SGSimpleSettings.shared.installedPluginsJson.data(using: .utf8),
let existing = try? JSONDecoder().decode([PluginInfo].self, from: data) {
plugins = existing
} else {
plugins = []
}
plugins.removeAll { $0.metadata.id == metadata.id }
plugins.append(pluginInfo)
if let data = try? JSONEncoder().encode(plugins),
let json = String(data: data, encoding: .utf8) {
SGSimpleSettings.shared.installedPluginsJson = json
SGSimpleSettings.shared.synchronizeShared()
}
// Reload plugins
PluginRunner.shared.ensureLoaded()
onSave(pluginInfo)
controller?.dismiss()
}
return controller
}
```
### `Swiftgram/SGSettingsUI/Sources/PluginInstallPopupController.swift`
```swift
// 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()
}
}
```
### `Swiftgram/SGSettingsUI/Sources/PluginSettingsController.swift`
```swift
// MARK: Swiftgram Plugin settings screen
import Foundation
import UIKit
import Display
import SwiftSignalKit
import TelegramPresentationData
import ItemListUI
import PresentationDataUtils
import AccountContext
import SGSimpleSettings
import SGItemListUI
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()
}
}
private enum PluginSettingsSection: Int32, SGItemListSection {
case main
case pluginOptions
case info
}
private typealias PluginSettingsEntry = SGItemListUIEntry<PluginSettingsSection, SGBoolSetting, AnyHashable, AnyHashable, AnyHashable, AnyHashable>
private let userDisplayBoolKeys: [(key: String, titleRu: String, titleEn: String)] = [
("enabled", "Включить подмену профиля", "Enable profile override"),
("fake_premium", "Premium статус", "Premium status"),
("fake_verified", "Статус верификации", "Verified status"),
("fake_scam", "Scam статус", "Scam status"),
("fake_fake", "Fake статус", "Fake status"),
("fake_support", "Support статус", "Support status"),
("fake_bot", "Bot статус", "Bot status"),
]
private let userDisplayStringKeys: [(key: String, titleRu: String, titleEn: String)] = [
("target_user_id", "Telegram ID пользователя", "User Telegram ID"),
("fake_first_name", "Имя", "First name"),
("fake_last_name", "Фамилия", "Last name"),
("fake_username", "Юзернейм (без @)", "Username (no @)"),
("fake_phone", "Номер телефона", "Phone number"),
("fake_id", "Telegram ID (визуально)", "Telegram ID (display)"),
]
private func pluginSettingsEntries(presentationData: PresentationData, plugin: PluginInfo) -> [PluginSettingsEntry] {
let lang = presentationData.strings.baseLanguageCode
let isRu = lang == "ru"
var entries: [PluginSettingsEntry] = []
let id = SGItemListCounter()
let host = PluginHost.shared
let pluginId = plugin.metadata.id
entries.append(.header(id: id.count, section: .main, text: isRu ? "ПЛАГИН" : "PLUGIN", badge: nil))
let enableText = plugin.enabled ? (isRu ? "Выключить плагин" : "Disable plugin") : (isRu ? "Включить плагин" : "Enable plugin")
entries.append(.action(id: id.count, section: .main, actionType: "toggleEnabled" as AnyHashable, text: enableText, kind: .generic))
entries.append(.notice(id: id.count, section: .main, text: isRu ? "Включает функциональность плагина." : "Enables plugin functionality."))
if plugin.metadata.hasUserDisplay {
entries.append(.header(id: id.count, section: .pluginOptions, text: isRu ? "НАСТРОЙКИ ОТОБРАЖЕНИЯ" : "DISPLAY SETTINGS", badge: nil))
entries.append(.notice(id: id.count, section: .pluginOptions, text: isRu ? "Оставьте поля пустыми, чтобы использовать реальные данные. Пустой «Telegram ID пользователя» — свой профиль." : "Leave fields empty to use real data. Empty «User Telegram ID» means your own profile."))
for item in userDisplayBoolKeys {
let value = host.getPluginSettingBool(pluginId: pluginId, key: item.key, default: false)
let label = value ? (isRu ? "Вкл" : "On") : (isRu ? "Выкл" : "Off")
let text = "\(isRu ? item.titleRu : item.titleEn): \(label)"
entries.append(.action(id: id.count, section: .pluginOptions, actionType: "pluginBool:\(item.key)" as AnyHashable, text: text, kind: .generic))
}
for item in userDisplayStringKeys {
let value = host.getPluginSetting(pluginId: pluginId, key: item.key) ?? ""
let label = value.isEmpty ? (isRu ? "" : "") : value
let text = "\(isRu ? item.titleRu : item.titleEn): \(label)"
entries.append(.action(id: id.count, section: .pluginOptions, actionType: "pluginString:\(item.key)" as AnyHashable, text: text, kind: .generic))
}
} else if plugin.hasSettings {
entries.append(.header(id: id.count, section: .pluginOptions, text: isRu ? "НАСТРОЙКИ" : "SETTINGS", badge: nil))
entries.append(.notice(id: id.count, section: .pluginOptions, text: isRu ? "Настройки этого плагина задаются в файле .plugin (create_settings). Редактор для других типов плагинов в разработке." : "Settings for this plugin are defined in the .plugin file (create_settings). Editor for other plugin types coming later."))
}
entries.append(.header(id: id.count, section: .info, text: isRu ? "ИНФОРМАЦИЯ" : "INFORMATION", badge: nil))
entries.append(PluginSettingsEntry.notice(id: id.count, section: .info, text: "\(plugin.metadata.name)\n\(isRu ? "Версия" : "Version") \(plugin.metadata.version)\n\(plugin.metadata.author)\n\n\(plugin.metadata.description)"))
return entries
}
public func PluginSettingsController(context: AccountContext, plugin: PluginInfo, onSave: @escaping () -> Void) -> ViewController {
let reloadPromise = ValuePromise(true, ignoreRepeated: false)
var backAction: (() -> Void)?
var presentAlertImpl: ((String, String, String, @escaping (String) -> Void) -> Void)?
let pluginId = plugin.metadata.id
let host = PluginHost.shared
let arguments = SGItemListArguments<SGBoolSetting, AnyHashable, AnyHashable, AnyHashable, AnyHashable>(
context: context,
setBoolValue: { _, _ in },
updateSliderValue: { _, _ in },
setOneFromManyValue: { _ in },
openDisclosureLink: { _ in },
action: { actionType in
guard let s = actionType as? String else { return }
if s == "toggleEnabled" {
var plugins = loadInstalledPlugins()
if let idx = plugins.firstIndex(where: { $0.metadata.id == pluginId }) {
plugins[idx].enabled.toggle()
saveInstalledPlugins(plugins)
reloadPromise.set(true)
onSave()
}
} else if s.hasPrefix("pluginBool:") {
let key = String(s.dropFirst("pluginBool:".count))
let current = host.getPluginSettingBool(pluginId: pluginId, key: key, default: false)
host.setPluginSettingBool(pluginId: pluginId, key: key, value: !current)
reloadPromise.set(true)
onSave()
} else if s.hasPrefix("pluginString:") {
let key = String(s.dropFirst("pluginString:".count))
let current = host.getPluginSetting(pluginId: pluginId, key: key) ?? ""
let titleRu = userDisplayStringKeys.first(where: { $0.key == key })?.titleRu ?? key
let titleEn = userDisplayStringKeys.first(where: { $0.key == key })?.titleEn ?? key
let lang = context.sharedContext.currentPresentationData.with { $0 }.strings.baseLanguageCode
let title = lang == "ru" ? titleRu : titleEn
presentAlertImpl?(key, title, current) { newValue in
host.setPluginSetting(pluginId: pluginId, key: key, value: newValue)
reloadPromise.set(true)
onSave()
}
}
},
searchInput: { _ in }
)
let signal = combineLatest(
reloadPromise.get(),
context.sharedContext.presentationData
)
|> map { _, presentationData -> (ItemListControllerState, (ItemListNodeState, SGItemListArguments<SGBoolSetting, AnyHashable, AnyHashable, AnyHashable, AnyHashable>)) in
let plugins = loadInstalledPlugins()
let currentPlugin = plugins.first(where: { $0.metadata.id == plugin.metadata.id }) ?? plugin
let controllerState = ItemListControllerState(
presentationData: ItemListPresentationData(presentationData),
title: .text(currentPlugin.metadata.name),
leftNavigationButton: ItemListNavigationButton(content: .text(presentationData.strings.Common_Back), style: .regular, enabled: true, action: { backAction?() }),
rightNavigationButton: nil,
backNavigationButton: ItemListBackButton(title: presentationData.strings.Common_Back)
)
let entries = pluginSettingsEntries(presentationData: presentationData, plugin: currentPlugin)
let listState = ItemListNodeState(
presentationData: ItemListPresentationData(presentationData),
entries: entries,
style: .blocks,
ensureVisibleItemTag: nil,
initialScrollToItem: nil
)
return (controllerState, (listState, arguments))
}
let controller = ItemListController(context: context, state: signal)
backAction = { [weak controller] in controller?.dismiss() }
presentAlertImpl = { [weak controller] key, title, currentValue, completion in
guard let c = controller else { return }
let alert = UIAlertController(title: title, message: nil, preferredStyle: .alert)
alert.addTextField { tf in
tf.text = currentValue
tf.placeholder = title
tf.autocapitalizationType = .none
tf.autocorrectionType = .no
}
let okTitle = context.sharedContext.currentPresentationData.with { $0 }.strings.Common_OK
let cancelTitle = context.sharedContext.currentPresentationData.with { $0 }.strings.Common_Cancel
alert.addAction(UIAlertAction(title: cancelTitle, style: .cancel))
alert.addAction(UIAlertAction(title: okTitle, style: .default) { _ in
let newValue = alert.textFields?.first?.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
completion(newValue)
})
c.present(alert, animated: true)
}
return controller
}
```
### `Swiftgram/SGSettingsUI/Sources/PluginRunner.swift`
```swift
// MARK: GLEGram JavaScript plugin runner (Ghostgram-style GLEGram.* API)
import Foundation
@preconcurrency import JavaScriptCore
import UIKit
import SGSimpleSettings
// MARK: - Plugin state
/// State for a single loaded JS plugin.
private final class JSPluginState {
let context: JSContext
var settingsItems: [(section: String, title: String, actionId: String, callback: JSValue)] = []
var chatMenuItems: [(title: String, callback: JSValue)] = []
var profileMenuItems: [(title: String, callback: JSValue)] = []
var onOutgoingMessage: JSValue?
var onIncomingMessage: JSValue?
var onOpenChat: JSValue?
var onOpenProfile: JSValue?
var openUrlHandler: JSValue?
var shouldShowMessage: JSValue?
var eventHandlers: [(eventName: String, callback: JSValue)] = []
init(context: JSContext) {
self.context = context
}
}
// MARK: - JS Bridge (Swift JavaScript)
/// JSExport protocol all methods listed here are exposed to the JS runtime.
@objc private protocol GLEGramJSBridgeExport: JSExport {
// ui
func uiAlert(_ title: String, _ message: String)
func uiPrompt(_ title: String, _ placeholder: String, _ callback: JSValue)
func uiHaptic(_ style: String)
func uiOpenURL(_ url: String)
func uiToast(_ message: String)
// compose
func composeGetText() -> String
func composeSetText(_ text: String)
func composeInsertText(_ text: String)
func composeOnSubmit(_ callback: JSValue)
// message actions
func messageActionsAddItem(_ title: String, _ callback: JSValue)
// intercept
func interceptOutgoing(_ callback: JSValue)
func interceptIncoming(_ callback: JSValue)
// network
func networkFetch(_ url: String, _ opts: NSDictionary, _ callback: JSValue)
// chat
func chatGetActive() -> NSDictionary?
func chatSend(_ peerId: Int64, _ text: String)
func chatEdit(_ peerId: Int64, _ msgId: Int64, _ text: String)
func chatDelete(_ peerId: Int64, _ msgId: Int64)
// profile
func profileAddAction(_ title: String, _ callback: JSValue)
// settings
func settingsAddItem(_ section: String, _ title: String, _ actionId: String, _ callback: JSValue)
func storageGet(_ key: String) -> String?
func storageSet(_ key: String, _ value: String)
// events
func eventsOn(_ name: String, _ callback: JSValue)
func eventsEmit(_ name: String, _ params: NSDictionary)
// ui extended
func uiConfirm(_ title: String, _ message: String, _ callback: JSValue)
func uiCopyToClipboard(_ text: String)
func uiShare(_ text: String)
}
/// Bridge object exposed to JS as `_bridge`. All GLEGram.* methods call through here.
private final class GLEGramJSBridge: NSObject, GLEGramJSBridgeExport {
@objc var pluginId: String = ""
weak var runner: PluginRunner?
// MARK: GLEGram.ui
@objc func uiAlert(_ title: String, _ message: String) {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
if let show = PluginHost.shared.showAlert {
show(title, message)
} else {
let scene = UIApplication.shared.connectedScenes.first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene
let window = scene?.windows.first(where: { $0.isKeyWindow }) ?? UIApplication.shared.windows.first(where: { $0.isKeyWindow })
if let root = window?.rootViewController {
var top = root
while let presented = top.presentedViewController {
top = presented
}
let alert = UIAlertController(title: title.isEmpty ? nil : title, message: message, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "OK", style: .default))
top.present(alert, animated: true)
}
}
}
}
@objc func uiPrompt(_ title: String, _ placeholder: String, _ callback: JSValue) {
DispatchQueue.main.async {
if let prompt = PluginHost.shared.showPrompt {
prompt(title, placeholder) { result in
callback.call(withArguments: [result ?? NSNull()])
}
} else if let window = UIApplication.shared.windows.first(where: { $0.isKeyWindow }),
let root = window.rootViewController {
let top = root.presentedViewController ?? root
let alert = UIAlertController(title: title.isEmpty ? nil : title, message: nil, preferredStyle: .alert)
alert.addTextField { tf in tf.placeholder = placeholder }
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { _ in
callback.call(withArguments: [NSNull()])
})
alert.addAction(UIAlertAction(title: "OK", style: .default) { _ in
let text = alert.textFields?.first?.text ?? ""
callback.call(withArguments: [text])
})
top.present(alert, animated: true)
}
}
}
@objc func uiHaptic(_ style: String) {
PluginHost.shared.haptic?(style)
}
@objc func uiOpenURL(_ url: String) {
DispatchQueue.main.async {
if let openURL = PluginHost.shared.openURL {
openURL(url)
} else if let u = URL(string: url) {
UIApplication.shared.open(u)
}
}
}
@objc func uiToast(_ message: String) {
DispatchQueue.main.async {
if let toast = PluginHost.shared.showToast {
toast(message)
} else {
PluginHost.shared.showBulletin?(message, .info)
}
}
}
@objc func uiConfirm(_ title: String, _ message: String, _ callback: JSValue) {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
if let confirm = PluginHost.shared.showConfirm {
confirm(title, message) { result in
DispatchQueue.main.async { callback.call(withArguments: [result]) }
}
} else if let window = UIApplication.shared.windows.first(where: { $0.isKeyWindow }),
let root = window.rootViewController {
let top = root.presentedViewController ?? root
let alert = UIAlertController(title: title.isEmpty ? nil : title, message: message.isEmpty ? nil : message, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "OK", style: .default) { _ in callback.call(withArguments: [true]) })
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { _ in callback.call(withArguments: [false]) })
top.present(alert, animated: true)
}
}
}
@objc func uiCopyToClipboard(_ text: String) {
DispatchQueue.main.async {
if let copy = PluginHost.shared.copyToClipboard {
copy(text)
} else {
UIPasteboard.general.string = text
}
}
}
@objc func uiShare(_ text: String) {
DispatchQueue.main.async {
if let share = PluginHost.shared.shareText {
share(text)
} else if let window = UIApplication.shared.windows.first(where: { $0.isKeyWindow }),
let root = window.rootViewController {
let top = root.presentedViewController ?? root
let vc = UIActivityViewController(activityItems: [text], applicationActivities: nil)
top.present(vc, animated: true)
}
}
}
// MARK: GLEGram.compose
@objc func composeGetText() -> String {
return PluginHost.shared.getInputText?() ?? ""
}
@objc func composeSetText(_ text: String) {
DispatchQueue.main.async {
PluginHost.shared.setInputText?(text)
}
}
@objc func composeInsertText(_ text: String) {
DispatchQueue.main.async {
if let insert = PluginHost.shared.insertText {
insert(text)
} else {
// Fallback: append to existing text
let current = PluginHost.shared.getInputText?() ?? ""
PluginHost.shared.setInputText?(current + text)
}
}
}
@objc func composeOnSubmit(_ callback: JSValue) {
guard !pluginId.isEmpty else { return }
runner?.setOnSubmitCallback(pluginId: pluginId, callback: callback)
}
// MARK: GLEGram.messageActions
@objc func messageActionsAddItem(_ title: String, _ callback: JSValue) {
guard !pluginId.isEmpty, !callback.isUndefined, !callback.isNull else { return }
runner?.addChatMenuItem(pluginId: pluginId, title: title, callback: callback)
}
// MARK: GLEGram.intercept
@objc func interceptOutgoing(_ callback: JSValue) {
guard !pluginId.isEmpty else { return }
runner?.setOnOutgoingMessage(pluginId: pluginId, callback: callback)
}
@objc func interceptIncoming(_ callback: JSValue) {
guard !pluginId.isEmpty else { return }
runner?.setOnIncomingMessage(pluginId: pluginId, callback: callback)
}
// MARK: GLEGram.network
@objc func networkFetch(_ url: String, _ opts: NSDictionary, _ callback: JSValue) {
let method = opts["method"] as? String ?? "GET"
let headers = opts["headers"] as? [String: String]
let body = opts["body"] as? String
PluginHost.shared.fetch?(url, method, headers, body) { error, data in
DispatchQueue.main.async {
if let error = error {
callback.call(withArguments: [error, NSNull()])
} else {
callback.call(withArguments: [NSNull(), data ?? ""])
}
}
}
}
// MARK: GLEGram.chat
@objc func chatGetActive() -> NSDictionary? {
guard let chat = PluginHost.shared.getCurrentChat?() else { return nil }
return ["accountId": NSNumber(value: chat.accountId), "peerId": NSNumber(value: chat.peerId)]
}
@objc func chatSend(_ peerId: Int64, _ text: String) {
guard let chat = PluginHost.shared.getCurrentChat?() else { return }
PluginHost.shared.sendMessage?(chat.accountId, peerId, text, nil, nil)
}
@objc func chatEdit(_ peerId: Int64, _ msgId: Int64, _ text: String) {
guard let chat = PluginHost.shared.getCurrentChat?() else { return }
PluginHost.shared.editMessage?(chat.accountId, peerId, msgId, text)
}
@objc func chatDelete(_ peerId: Int64, _ msgId: Int64) {
guard let chat = PluginHost.shared.getCurrentChat?() else { return }
PluginHost.shared.deleteMessage?(chat.accountId, peerId, msgId)
}
// MARK: GLEGram.peerProfile
@objc func profileAddAction(_ title: String, _ callback: JSValue) {
guard !pluginId.isEmpty, !callback.isUndefined, !callback.isNull else { return }
runner?.addProfileMenuItem(pluginId: pluginId, title: title, callback: callback)
}
// MARK: GLEGram.settings
@objc func settingsAddItem(_ section: String, _ title: String, _ actionId: String, _ callback: JSValue) {
guard !pluginId.isEmpty, !callback.isUndefined, !callback.isNull else { return }
runner?.addSettingsItem(pluginId: pluginId, section: section, title: title, actionId: actionId, callback: callback)
}
@objc func storageGet(_ key: String) -> String? {
guard !pluginId.isEmpty else { return nil }
return PluginHost.shared.getPluginSetting(pluginId: pluginId, key: key)
}
@objc func storageSet(_ key: String, _ value: String) {
guard !pluginId.isEmpty else { return }
PluginHost.shared.setPluginSetting(pluginId: pluginId, key: key, value: value)
}
// MARK: GLEGram.events
@objc func eventsOn(_ name: String, _ callback: JSValue) {
guard !pluginId.isEmpty, !callback.isUndefined, !callback.isNull else { return }
runner?.addEventListener(pluginId: pluginId, eventName: name, callback: callback)
}
@objc func eventsEmit(_ name: String, _ params: NSDictionary) {
let dict = params as? [String: Any] ?? [:]
_ = SGPluginHooks.emitEvent(name, dict)
}
}
// MARK: - Plugin Runner
/// Singleton that manages JavaScript plugins via JavaScriptCore.
public final class PluginRunner {
public static let shared = PluginRunner()
private let queue = DispatchQueue(label: "GLEGramPluginRunner", qos: .userInitiated)
private var loadedPlugins: [String: JSPluginState] = [:]
private let lock = NSLock()
private let loadLock = NSLock()
private static var incomingMessageObserver: NSObjectProtocol?
private static var technicalEventObserver: NSObjectProtocol?
// MARK: - Bootstrap Script (GLEGram.* API)
private static let bootstrapScript = """
(function() {
if (typeof GLEGram !== 'undefined') return;
var b = (typeof _bridge !== 'undefined') ? _bridge : null;
function s(v) { return v != null ? String(v) : ''; }
GLEGram = {
ui: {
alert: function(title, msg) { if (b && b.uiAlert) b.uiAlert(s(title), s(msg)); },
prompt: function(title, placeholder, cb) { if (b && b.uiPrompt && cb) b.uiPrompt(s(title), s(placeholder), cb); },
haptic: function(style) { if (b && b.uiHaptic) b.uiHaptic(s(style) || 'light'); },
openURL: function(url) { if (b && b.uiOpenURL) b.uiOpenURL(s(url)); },
toast: function(msg) { if (b && b.uiToast) b.uiToast(s(msg)); },
confirm: function(title, msg, cb) { if (b && b.uiConfirm && cb) b.uiConfirm(s(title), s(msg || ''), cb); },
copyToClipboard: function(text) { if (b && b.uiCopyToClipboard) b.uiCopyToClipboard(s(text)); },
share: function(text) { if (b && b.uiShare) b.uiShare(s(text)); }
},
compose: {
getText: function() { return b && b.composeGetText ? b.composeGetText() : ''; },
setText: function(text) { if (b && b.composeSetText) b.composeSetText(s(text)); },
insertText: function(text) { if (b && b.composeInsertText) b.composeInsertText(s(text)); },
onSubmit: function(cb) { if (b && b.composeOnSubmit) b.composeOnSubmit(cb); }
},
messageActions: {
addItem: function(title, cb) { if (b && b.messageActionsAddItem && cb) b.messageActionsAddItem(s(title), cb); }
},
intercept: {
outgoingMessage: function(cb) { if (b && b.interceptOutgoing) b.interceptOutgoing(cb); },
incomingMessage: function(cb) { if (b && b.interceptIncoming) b.interceptIncoming(cb); }
},
network: {
fetch: function(url, opts, cb) { if (b && b.networkFetch && cb) b.networkFetch(s(url), opts || {}, cb); }
},
chat: {
getActiveChat: function() { return b && b.chatGetActive ? b.chatGetActive() : null; },
sendMessage: function(peerId, text) { if (b && b.chatSend) b.chatSend(Number(peerId) || 0, s(text)); },
editMessage: function(peerId, msgId, text) { if (b && b.chatEdit) b.chatEdit(Number(peerId) || 0, Number(msgId) || 0, s(text)); },
deleteMessage: function(peerId, msgId) { if (b && b.chatDelete) b.chatDelete(Number(peerId) || 0, Number(msgId) || 0); }
},
peerProfile: {
addAction: function(title, cb) { if (b && b.profileAddAction && cb) b.profileAddAction(s(title), cb); }
},
settings: {
addItem: function(section, title, actionId, cb) { if (b && b.settingsAddItem && cb) b.settingsAddItem(s(section), s(title), s(actionId), cb); },
getStorage: function(key) { return b && b.storageGet ? (b.storageGet(s(key)) || null) : null; },
setStorage: function(key, val) { if (b && b.storageSet) b.storageSet(s(key), s(val)); }
},
events: {
on: function(name, cb) { if (b && b.eventsOn) b.eventsOn(s(name), cb); },
emit: function(name, params) { if (b && b.eventsEmit) b.eventsEmit(s(name), params || {}); }
}
};
})();
"""
public init() {}
// MARK: - Load / Unload
public func ensureLoaded() {
queue.async { [weak self] in self?.ensureLoadedSync() }
}
private func ensureLoadedSync() {
guard GLEGramFeatures.pluginsEnabled else {
shutdown()
return
}
guard SGSimpleSettings.shared.pluginsJavaScriptBridgeActive else {
shutdown()
return
}
loadLock.lock()
defer { loadLock.unlock() }
var toLoad: [(id: String, path: String, name: String)] = []
if let data = SGSimpleSettings.shared.installedPluginsJson.data(using: .utf8),
let plugins = try? JSONDecoder().decode([PluginInfo].self, from: data) {
for plugin in plugins where plugin.enabled && (plugin.path as NSString).pathExtension.lowercased() == "js" {
let id = plugin.metadata.id
lock.lock()
let alreadyLoaded = loadedPlugins[id] != nil
lock.unlock()
if !alreadyLoaded {
toLoad.append((id, plugin.path, plugin.metadata.name))
}
}
}
for (id, path, name) in toLoad {
loadPluginSync(id: id, path: path, name: name)
}
registerAllHooks()
}
/// Load a single plugin. Shows alert on success if showNotification is true.
private func loadPluginSync(id: String, path: String, name: String, showNotification: Bool = true) {
guard GLEGramFeatures.pluginsEnabled else { return }
guard (path as NSString).pathExtension.lowercased() == "js" else { return }
// Resolve path: try disk first, then bundle
var resolvedPath = path
if !FileManager.default.fileExists(atPath: resolvedPath) {
if let bundlePath = Bundle.main.path(forResource: id, ofType: "js", inDirectory: "Plugins") {
resolvedPath = bundlePath
}
}
guard let script = try? String(contentsOf: URL(fileURLWithPath: resolvedPath), encoding: .utf8) else {
NSLog("[GLEGram PluginRunner] Failed to read: \(path)")
return
}
let context = JSContext()!
let state = JSPluginState(context: context)
// Register state BEFORE evaluating script so bridge calls can store items
lock.lock()
loadedPlugins[id] = state
lock.unlock()
let bridge = GLEGramJSBridge()
bridge.pluginId = id
bridge.runner = self
context.setObject(bridge, forKeyedSubscript: "_bridge" as NSString)
context.exceptionHandler = { _, value in
if let v = value, !v.isUndefined {
NSLog("[GLEGram Plugin %@] JS error: %@", id, v.toString() ?? "")
}
}
// Evaluate bootstrap
context.exception = nil
context.evaluateScript(PluginRunner.bootstrapScript)
if context.exception != nil {
NSLog("[GLEGram PluginRunner] Bootstrap error in \(id): \(context.exception!.toString() ?? "")")
lock.lock()
loadedPlugins.removeValue(forKey: id)
lock.unlock()
return
}
// Evaluate plugin script
context.exception = nil
context.evaluateScript(script)
if context.exception != nil {
NSLog("[GLEGram PluginRunner] Script error in \(id): \(context.exception!.toString() ?? "")")
lock.lock()
loadedPlugins.removeValue(forKey: id)
lock.unlock()
return
}
NSLog("[GLEGram PluginRunner] Loaded plugin: \(id)")
// Show success alert
if showNotification {
let displayName = name.isEmpty ? id : name
DispatchQueue.main.async {
if let bulletin = PluginHost.shared.showBulletin {
bulletin("Плагин «\(displayName)» запущен", .success)
} else if let alert = PluginHost.shared.showAlert {
alert("Плагин запущен", "«\(displayName)» успешно загружен и работает.")
}
}
}
}
public func unload(pluginId: String) {
lock.lock()
loadedPlugins.removeValue(forKey: pluginId)
lock.unlock()
registerAllHooks()
}
public func shutdown() {
lock.lock()
loadedPlugins.removeAll()
lock.unlock()
if let o = PluginRunner.incomingMessageObserver {
NotificationCenter.default.removeObserver(o)
PluginRunner.incomingMessageObserver = nil
}
if let o = PluginRunner.technicalEventObserver {
NotificationCenter.default.removeObserver(o)
PluginRunner.technicalEventObserver = nil
}
SGPluginHooks.willOpenChatRunner = nil
SGPluginHooks.willOpenProfileRunner = nil
SGPluginHooks.chatMenuItemsProvider = nil
SGPluginHooks.profileMenuItemsProvider = nil
SGPluginHooks.messageHookRunner = nil
SGPluginHooks.didSendMessageRunner = nil
SGPluginHooks.incomingMessageHookRunner = nil
SGPluginHooks.openUrlRunner = nil
SGPluginHooks.shouldShowMessageRunner = nil
SGPluginHooks.shouldShowGiftButtonRunner = nil
SGPluginHooks.userDisplayRunner = nil
SGPluginHooks.eventRunner = nil
}
// MARK: - State setters (called by bridge during script evaluation)
func addSettingsItem(pluginId: String, section: String, title: String, actionId: String, callback: JSValue) {
lock.lock()
loadedPlugins[pluginId]?.settingsItems.append((section: section, title: title, actionId: actionId, callback: callback))
lock.unlock()
}
func addChatMenuItem(pluginId: String, title: String, callback: JSValue) {
lock.lock()
loadedPlugins[pluginId]?.chatMenuItems.append((title: title, callback: callback))
lock.unlock()
}
func addProfileMenuItem(pluginId: String, title: String, callback: JSValue) {
lock.lock()
loadedPlugins[pluginId]?.profileMenuItems.append((title: title, callback: callback))
lock.unlock()
}
func setOnOutgoingMessage(pluginId: String, callback: JSValue) {
lock.lock()
loadedPlugins[pluginId]?.onOutgoingMessage = callback
lock.unlock()
}
func setOnIncomingMessage(pluginId: String, callback: JSValue) {
lock.lock()
loadedPlugins[pluginId]?.onIncomingMessage = callback
lock.unlock()
}
func setOnSubmitCallback(pluginId: String, callback: JSValue) {
// Store as event handler with special name
lock.lock()
loadedPlugins[pluginId]?.eventHandlers.append((eventName: "__compose.onSubmit", callback: callback))
lock.unlock()
}
func addEventListener(pluginId: String, eventName: String, callback: JSValue) {
lock.lock()
loadedPlugins[pluginId]?.eventHandlers.append((eventName: eventName, callback: callback))
lock.unlock()
}
// MARK: - Hook registration
private func registerAllHooks() {
guard GLEGramFeatures.pluginsEnabled else { shutdown(); return }
let block = { [weak self] in
guard let self = self else { return }
SGPluginHooks.willOpenChatRunner = { [weak self] accountId, peerId in
self?.notifyOpenChat(accountId: accountId, peerId: peerId)
}
SGPluginHooks.willOpenProfileRunner = { [weak self] accountId, peerId in
self?.notifyOpenProfile(accountId: accountId, peerId: peerId)
}
SGPluginHooks.chatMenuItemsProvider = { [weak self] accountId, peerId, messageId in
self?.getChatMenuItems(accountId: accountId, peerId: peerId, messageId: messageId) ?? []
}
SGPluginHooks.profileMenuItemsProvider = { [weak self] accountId, peerId in
self?.getProfileMenuItems(accountId: accountId, peerId: peerId) ?? []
}
SGPluginHooks.messageHookRunner = { [weak self] accountPeerId, peerId, text, replyTo in
guard SGSimpleSettings.shared.pluginsJavaScriptBridgeActive else { return nil }
return self?.applyOutgoingMessageHook(accountPeerId: accountPeerId, peerId: peerId, text: text, replyToMessageId: replyTo)
}
SGPluginHooks.didSendMessageRunner = { [weak self] accountId, peerId, text in
guard SGSimpleSettings.shared.pluginsJavaScriptBridgeActive else { return }
_ = self?.applyEvent(name: "message.didSend", params: ["accountId": accountId, "peerId": peerId, "text": text])
}
SGPluginHooks.incomingMessageHookRunner = { [weak self] accountId, peerId, messageId, text, outgoing in
guard SGSimpleSettings.shared.pluginsJavaScriptBridgeActive else { return }
self?.notifyIncomingMessage(accountId: accountId, peerId: peerId, messageId: messageId, text: text, outgoing: outgoing)
}
SGPluginHooks.openUrlRunner = { [weak self] url in
guard SGSimpleSettings.shared.pluginsJavaScriptBridgeActive else { return false }
return self?.applyOpenUrlHook(url: url) ?? false
}
SGPluginHooks.shouldShowMessageRunner = { [weak self] accountId, peerId, messageId, text, outgoing in
guard SGSimpleSettings.shared.pluginsJavaScriptBridgeActive else { return true }
return self?.applyShouldShowMessage(accountId: accountId, peerId: peerId, messageId: messageId, text: text, outgoing: outgoing) ?? true
}
SGPluginHooks.shouldShowGiftButtonRunner = { _, _ in true }
SGPluginHooks.eventRunner = { [weak self] name, params in
guard SGSimpleSettings.shared.pluginsJavaScriptBridgeActive else { return nil }
return self?.applyEvent(name: name, params: params)
}
// Observe incoming messages
if PluginRunner.incomingMessageObserver == nil {
PluginRunner.incomingMessageObserver = NotificationCenter.default.addObserver(
forName: SGPluginIncomingMessageNotificationName, object: nil, queue: .main
) { [weak self] note in
guard let u = note.userInfo else { return }
self?.notifyIncomingMessage(
accountId: (u["accountId"] as? NSNumber)?.int64Value ?? 0,
peerId: (u["peerId"] as? NSNumber)?.int64Value ?? 0,
messageId: (u["messageId"] as? NSNumber)?.int64Value ?? 0,
text: u["text"] as? String,
outgoing: (u["outgoing"] as? NSNumber)?.boolValue ?? false
)
}
}
if PluginRunner.technicalEventObserver == nil {
PluginRunner.technicalEventObserver = NotificationCenter.default.addObserver(
forName: SGPluginTechnicalEventNotificationName, object: nil, queue: .main
) { [weak self] note in
guard let u = note.userInfo,
let eventName = u["eventName"] as? String,
let params = u["params"] as? [String: Any] else { return }
_ = self?.applyEvent(name: eventName, params: params)
}
}
}
if Thread.isMainThread { block() } else { DispatchQueue.main.async(execute: block) }
}
// MARK: - Hook execution
public func getChatMenuItems(accountId: Int64, peerId: Int64, messageId: Int64? = nil) -> [PluginChatMenuItem] {
guard GLEGramFeatures.pluginsEnabled else { return [] }
let msgId = messageId ?? 0
var items: [PluginChatMenuItem] = []
lock.lock()
for (_, state) in loadedPlugins {
for item in state.chatMenuItems {
nonisolated(unsafe) let cb = item.callback
items.append(PluginChatMenuItem(title: item.title, action: {
DispatchQueue.main.async {
let ctx: [String: Any] = ["peerId": NSNumber(value: peerId), "messageId": NSNumber(value: msgId)]
cb.call(withArguments: [ctx])
}
}))
}
}
lock.unlock()
return items
}
public func getProfileMenuItems(accountId: Int64, peerId: Int64) -> [PluginChatMenuItem] {
guard GLEGramFeatures.pluginsEnabled else { return [] }
var items: [PluginChatMenuItem] = []
lock.lock()
for (_, state) in loadedPlugins {
for item in state.profileMenuItems {
nonisolated(unsafe) let cb = item.callback
items.append(PluginChatMenuItem(title: item.title, action: {
DispatchQueue.main.async {
let ctx: [String: Any] = ["peerId": NSNumber(value: peerId)]
cb.call(withArguments: [ctx])
}
}))
}
}
lock.unlock()
return items
}
/// Run a settings action by pluginId and actionId.
public func runAction(pluginId: String, actionId: String) {
DispatchQueue.main.async { [weak self] in
self?.lock.lock()
guard let state = self?.loadedPlugins[pluginId] else { self?.lock.unlock(); return }
guard let item = state.settingsItems.first(where: { $0.actionId == actionId }) else { self?.lock.unlock(); return }
let callback = item.callback
self?.lock.unlock()
callback.call(withArguments: [])
}
}
/// Get settings items filtered by section (compatibility with GLEGramSettingsController).
public func getSettingsItems(section: String) -> [(pluginId: String, section: String, title: String, actionId: String)] {
return allSettingsItems().filter { $0.section.lowercased() == section.lowercased() }
}
/// Get all settings items from loaded plugins.
public func allSettingsItems() -> [(pluginId: String, section: String, title: String, actionId: String)] {
lock.lock()
defer { lock.unlock() }
var result: [(pluginId: String, section: String, title: String, actionId: String)] = []
for (pid, state) in loadedPlugins {
for item in state.settingsItems {
result.append((pluginId: pid, section: item.section, title: item.title, actionId: item.actionId))
}
}
return result
}
private func applyOutgoingMessageHook(accountPeerId: Int64, peerId: Int64, text: String, replyToMessageId: Int64?) -> SGPluginHookResult? {
lock.lock()
let plugins = loadedPlugins.compactMap { _, state -> JSValue? in
guard let cb = state.onOutgoingMessage, !cb.isUndefined else { return nil }
return cb
}
lock.unlock()
guard !plugins.isEmpty else { return nil }
let replyId = replyToMessageId ?? 0
let msg: [String: Any] = [
"accountId": NSNumber(value: accountPeerId),
"peerId": NSNumber(value: peerId),
"text": text,
"replyTo": NSNumber(value: replyId)
]
func run() -> SGPluginHookResult? {
for callback in plugins {
guard let res = callback.call(withArguments: [msg]), !res.isUndefined, !res.isNull else { continue }
if let action = res.forProperty("action")?.toString() {
if action == "modify", let newText = res.forProperty("text")?.toString() {
return SGPluginHookResult(strategy: .modify, message: newText)
}
if action == "cancel" {
return SGPluginHookResult(strategy: .cancel)
}
}
}
return nil
}
if Thread.isMainThread { return run() }
var result: SGPluginHookResult?
DispatchQueue.main.sync { result = run() }
return result
}
private func notifyIncomingMessage(accountId: Int64, peerId: Int64, messageId: Int64, text: String?, outgoing: Bool) {
lock.lock()
let callbacks = loadedPlugins.compactMap { _, state -> JSValue? in
guard let cb = state.onIncomingMessage, !cb.isUndefined else { return nil }
return cb
}
lock.unlock()
let msg: [String: Any] = [
"accountId": NSNumber(value: accountId),
"peerId": NSNumber(value: peerId),
"messageId": NSNumber(value: messageId),
"text": text ?? "",
"outgoing": outgoing
]
for cb in callbacks {
cb.call(withArguments: [msg])
}
}
private func notifyOpenChat(accountId: Int64, peerId: Int64) {
lock.lock()
let callbacks = loadedPlugins.compactMap { _, state -> JSValue? in
guard let cb = state.onOpenChat, !cb.isUndefined else { return nil }
return cb
}
lock.unlock()
for cb in callbacks {
cb.call(withArguments: [NSNumber(value: accountId), NSNumber(value: peerId)])
}
}
private func notifyOpenProfile(accountId: Int64, peerId: Int64) {
lock.lock()
let callbacks = loadedPlugins.compactMap { _, state -> JSValue? in
guard let cb = state.onOpenProfile, !cb.isUndefined else { return nil }
return cb
}
lock.unlock()
for cb in callbacks {
cb.call(withArguments: [NSNumber(value: accountId), NSNumber(value: peerId)])
}
}
private func applyOpenUrlHook(url: String) -> Bool {
lock.lock()
let callbacks = loadedPlugins.compactMap { _, state -> JSValue? in
guard let cb = state.openUrlHandler, !cb.isUndefined else { return nil }
return cb
}
lock.unlock()
for cb in callbacks {
if let res = cb.call(withArguments: [url]), res.toBool() { return true }
}
return false
}
private func applyShouldShowMessage(accountId: Int64, peerId: Int64, messageId: Int64, text: String?, outgoing: Bool) -> Bool {
lock.lock()
let callbacks = loadedPlugins.compactMap { _, state -> JSValue? in
guard let cb = state.shouldShowMessage, !cb.isUndefined else { return nil }
return cb
}
lock.unlock()
for cb in callbacks {
if let res = cb.call(withArguments: [NSNumber(value: accountId), NSNumber(value: peerId), NSNumber(value: messageId), text ?? "", outgoing]) {
if !res.isUndefined && !res.isNull && !res.toBool() { return false }
}
}
return true
}
private func applyEvent(name: String, params: [String: Any]) -> [String: Any]? {
lock.lock()
let handlers = loadedPlugins.flatMap { _, state in
state.eventHandlers.filter { $0.eventName == name }.map { $0.callback }
}
lock.unlock()
for cb in handlers {
if let res = cb.call(withArguments: [params as NSDictionary]), !res.isUndefined, !res.isNull {
if let dict = res.toDictionary() as? [String: Any] {
if dict["cancel"] as? Bool == true { return dict }
}
}
}
return nil
}
/// Fire a wg-style hook for compatibility. Fires event with given name for all plugins.
public func fireWgHook(_ hookName: String, args: [Any]) {
guard GLEGramFeatures.pluginsEnabled else { return }
_ = applyEvent(name: hookName, params: ["args": args])
}
/// Run a wg-style hook (compatibility shim).
public func runWgHook(pluginId: String, hookName: String, peerId: Int64 = 0, messageId: Int64 = 0) {
// No-op: wg API removed. Kept for compile compatibility.
}
public func runWgHookSync(pluginId: String, hookName: String, args: [Any]) {
// No-op: wg API removed.
}
/// Returns wg settings rows (compatibility shim, empty).
public func wgSettingsRows(for pluginId: String) -> [(id: String, title: String, subtitle: String, hookName: String)] {
return []
}
/// Returns wg context menu items (compatibility shim, empty).
public func wgContextMenuItems(for pluginId: String) -> [(title: String, hookName: String)] {
return []
}
}
// MARK: - Public settings items struct (for GLEGramSettingsController compatibility)
public struct JSPluginSettingsItem {
public let pluginId: String
public let section: String
public let title: String
public let actionId: String
public let callback: JSValue
}
```
### `Swiftgram/SGSettingsUI/Sources/GLEGramSettingsController.swift`
```swift
// MARK: Swiftgram
import SGSimpleSettings
import SGStrings
import SGItemListUI
import SGSupporters
#if canImport(SGDeletedMessages)
import SGDeletedMessages
#endif
import Foundation
import UIKit
import AppBundle
import CoreText
import CoreGraphics
#if canImport(UniformTypeIdentifiers)
import UniformTypeIdentifiers
#endif
import Display
import PromptUI
import SwiftSignalKit
import Postbox
import TelegramCore
import TelegramPresentationData
import ItemListUI
import PresentationDataUtils
import OverlayStatusController
import UndoUI
import AccountContext
import LegacyUI
import LegacyMediaPickerUI
#if canImport(SGFakeLocation)
import SGFakeLocation
#endif
#if canImport(FaceScanScreen)
import FaceScanScreen
#endif
// MARK: - Back button helper
private class BackButtonTarget: NSObject {
private weak var controller: UIViewController?
init(controller: UIViewController) {
self.controller = controller
}
@objc func backAction() {
if let nav = controller?.navigationController, nav.viewControllers.count > 1 {
nav.popViewController(animated: true)
} else {
controller?.dismiss(animated: true)
}
}
}
private var backButtonTargetKey: UInt8 = 0
private func makeBackBarButtonItem(presentationData: PresentationData, controller: ViewController) -> UIBarButtonItem {
let target = BackButtonTarget(controller: controller)
objc_setAssociatedObject(controller, &backButtonTargetKey, target, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
return UIBarButtonItem(backButtonAppearanceWithTitle: presentationData.strings.Common_Back, target: target, action: #selector(BackButtonTarget.backAction))
}
/// Масштабирует изображение до maxSize по большей стороне с чётким рендером (как иконки «Канал, Чат, Форум»).
private func scaleImageForListIcon(_ image: UIImage, maxSize: CGFloat) -> UIImage? {
let size = image.size
guard size.width > 0, size.height > 0 else { return image }
guard size.width > maxSize || size.height > maxSize else { return image }
let scale = min(maxSize / size.width, maxSize / size.height)
let newSizePt = CGSize(width: size.width * scale, height: size.height * scale)
let screenScale = UIScreen.main.scale
let format = UIGraphicsImageRendererFormat()
format.scale = screenScale
format.opaque = false
let renderer = UIGraphicsImageRenderer(size: newSizePt, format: format)
return renderer.image { ctx in
ctx.cgContext.interpolationQuality = .high
image.draw(in: CGRect(origin: .zero, size: newSizePt))
}
}
private enum GLEGramTab: Int, CaseIterable {
case appearance = 0
case security
case other
}
private enum GLEGramSection: Int32, SGItemListSection {
case search
case functions
case links
case messages
case chatList
case onlineStatus
case readReceipts
case content
case localPremium
case interface
case appearance
case fontReplacement
case fakeLocation
case onlineStatusRecording
case doubleBottom
case protectedChats
case voiceChanger
case other
}
private func tab(for section: GLEGramSection) -> GLEGramTab {
switch section {
case .search: return .appearance
case .functions, .links: return .appearance
case .localPremium, .interface, .appearance, .fontReplacement: return .appearance
case .messages, .chatList, .onlineStatus, .readReceipts, .content, .fakeLocation, .onlineStatusRecording, .doubleBottom, .protectedChats, .voiceChanger: return .security
case .other: return .other
}
}
private func sectionForEntry(_ entry: GLEGramEntry) -> GLEGramSection {
switch entry {
case .header(_, let s, _, _): return s
case .toggle(_, let s, _, _, _, _): return s
case .toggleWithIcon(_, let s, _, _, _, _, _): return s
case .notice(_, let s, _): return s
case .percentageSlider(_, let s, _, _, _, _): return s
case .delaySecondsSlider(_, let s, _, _, _, _, _): return s
case .fontSizeMultiplierSlider(_, let s, _, _): return s
case .oneFromManySelector(_, let s, _, _, _, _): return s
case .disclosure(_, let s, _, _): return s
case .disclosureWithIcon(_, let s, _, _, _): return s
case .peerColorDisclosurePreview(_, let s, _, _): return s
case .action(_, let s, _, _, _): return s
case .searchInput(_, let s, _, _, _): return s
case .reorderableRow(_, let s, _, _, _): return s
}
}
private func gleGramEntriesFiltered(by selectedTab: GLEGramTab, entries: [GLEGramEntry]) -> [GLEGramEntry] {
entries.filter { entry in
let sec = sectionForEntry(entry)
return sec == .search || tab(for: sec) == selectedTab
}
}
private func glegSelfChatTitleModeLabel(_ mode: SelfChatTitleMode, lang: String) -> String {
switch mode {
case .default:
return lang == "ru" ? "Как в Telegram" : "Like Telegram"
case .displayName:
return lang == "ru" ? "Имя профиля" : "Display name"
case .username:
return "@username"
}
}
/// Root GLEGram screen: exteraGram-style header (icon + title + tagline), Функции (4 tabs), Ссылки (Канал, Чат, Форум).
private func gleGramRootEntries(presentationData: PresentationData) -> [GLEGramEntry] {
let lang = presentationData.strings.baseLanguageCode
var entries: [GLEGramEntry] = []
let id = SGItemListCounter()
let functionsHeader = lang == "ru" ? "ФУНКЦИИ" : "FEATURES"
let linksHeader = lang == "ru" ? "ССЫЛКИ" : "LINKS"
let appearanceTitle = lang == "ru" ? "Оформление" : "Appearance"
let securityTitle = lang == "ru" ? "Приватность" : "Privacy"
let otherTitle = lang == "ru" ? "Другие функции" : "Other"
let channelTitle = lang == "ru" ? "Канал" : "Channel"
let chatTitle = lang == "ru" ? "Чат" : "Chat"
let forumTitle = lang == "ru" ? "Форум" : "Forum"
entries.append(.header(id: id.count, section: .functions, text: functionsHeader, badge: nil))
entries.append(GLEGramEntry.disclosureWithIcon(id: id.count, section: .functions, link: .appearanceTab, text: appearanceTitle, iconRef: "GLEGramTabAppearance"))
entries.append(GLEGramEntry.disclosureWithIcon(id: id.count, section: .functions, link: .securityTab, text: securityTitle, iconRef: "GLEGramTabSecurity"))
entries.append(GLEGramEntry.disclosureWithIcon(id: id.count, section: .functions, link: .otherTab, text: otherTitle, iconRef: "GLEGramTabOther"))
if GLEGramFeatures.pluginsEnabled {
let pluginsTitle = lang == "ru" ? "Плагины" : "Plugins"
entries.append(GLEGramEntry.disclosureWithIcon(id: id.count, section: .functions, link: .pluginsSettings, text: pluginsTitle, iconRef: "glePlugins/1"))
}
entries.append(.header(id: id.count, section: .links, text: linksHeader, badge: nil))
entries.append(GLEGramEntry.disclosureWithIcon(id: id.count, section: .links, link: .channelLink, text: channelTitle, iconRef: "Settings/Menu/Channels"))
entries.append(GLEGramEntry.disclosureWithIcon(id: id.count, section: .links, link: .chatLink, text: chatTitle, iconRef: "Settings/Menu/GroupChats"))
entries.append(GLEGramEntry.disclosureWithIcon(id: id.count, section: .links, link: .forumLink, text: forumTitle, iconRef: "Settings/Menu/Topics"))
if let status = cachedGLEGramUserStatus(), status.access.betaBuilds, let betaConfig = status.betaConfig, betaConfig.channelUrl != nil {
let betaHeader = lang == "ru" ? "БЕТА" : "BETA"
entries.append(.header(id: id.count, section: .links, text: betaHeader, badge: nil))
let betaChannelTitle = lang == "ru" ? "Перейти в канал с бета-версиями" : "Go to Beta Channel"
entries.append(GLEGramEntry.disclosure(id: id.count, section: .links, link: .betaChannel, text: betaChannelTitle))
}
return entries
}
private enum GLEGramSliderSetting: Hashable {
case fontReplacementSize
case ghostModeMessageSendDelay
case avatarRoundingPercent
}
private enum GLEGramOneFromManySetting: Hashable {
case onlineStatusRecordingInterval
case selfChatTitleMode
}
private enum GLEGramDisclosureLink: Hashable {
case fakeLocationPicker
case tabOrganizer
case profileCover
case fontReplacementPicker
case fontReplacementBoldPicker
case fontReplacementImportFile
case fontReplacementBoldImportFile
case appearanceTab
case securityTab
case otherTab
case fakeProfileSettings
case feelRichAmount
case savedDeletedMessagesList
case doubleBottomSettings
case protectedChatsSettings
/// GLEGram root: Plugins list (JS + .plugin).
case pluginsSettings
/// Links section: open t.me URLs.
case channelLink
case chatLink
case forumLink
/// Beta section: channel with beta versions.
case betaChannel
/// Voice Morpher preset (ghostgram-style local DSP).
case voiceChangerVoicePicker
}
private typealias GLEGramEntry = SGItemListUIEntry<GLEGramSection, SGBoolSetting, GLEGramSliderSetting, GLEGramOneFromManySetting, GLEGramDisclosureLink, AnyHashable>
private struct GLEGramSettingsControllerState: Equatable {
var searchQuery: String?
var selectedTab: GLEGramTab = .appearance
}
private func gleGramEntries(presentationData: PresentationData, contentSettingsConfiguration: ContentSettingsConfiguration?, state: GLEGramSettingsControllerState, mediaBoxBasePath: String) -> [GLEGramEntry] {
let lang = presentationData.strings.baseLanguageCode
let strings = presentationData.strings
var entries: [GLEGramEntry] = []
let id = SGItemListCounter()
entries.append(.searchInput(id: id.count, section: .search, title: NSAttributedString(string: "🔍"), text: state.searchQuery ?? "", placeholder: strings.Common_Search))
// MARK: Messages
entries.append(.header(id: id.count, section: .messages, text: i18n("Settings.DeletedMessages.Header", lang), badge: nil))
let showDeleted = SGSimpleSettings.shared.showDeletedMessages
entries.append(.toggle(id: id.count, section: .messages, settingName: .showDeletedMessages, value: showDeleted, text: i18n("Settings.DeletedMessages.Save", lang), enabled: true))
entries.append(.notice(id: id.count, section: .messages, text: i18n("Settings.DeletedMessages.Save.Notice", lang)))
entries.append(.toggle(id: id.count, section: .messages, settingName: .saveDeletedMessagesMedia, value: SGSimpleSettings.shared.saveDeletedMessagesMedia, text: i18n("Settings.DeletedMessages.SaveMedia", lang), enabled: showDeleted))
entries.append(.toggle(id: id.count, section: .messages, settingName: .saveDeletedMessagesReactions, value: SGSimpleSettings.shared.saveDeletedMessagesReactions, text: i18n("Settings.DeletedMessages.SaveReactions", lang), enabled: showDeleted))
entries.append(.toggle(id: id.count, section: .messages, settingName: .saveDeletedMessagesForBots, value: SGSimpleSettings.shared.saveDeletedMessagesForBots, text: i18n("Settings.DeletedMessages.SaveForBots", lang), enabled: showDeleted))
let storageSizeFormatted = ByteCountFormatter.string(fromByteCount: SGDeletedMessages.storageSizeBytes(mediaBoxBasePath: mediaBoxBasePath), countStyle: .file)
entries.append(.notice(id: id.count, section: .messages, text: i18n("Settings.DeletedMessages.StorageSize", lang) + ": " + storageSizeFormatted))
entries.append(.disclosure(id: id.count, section: .messages, link: .savedDeletedMessagesList, text: (lang == "ru" ? "Просмотреть сохранённые" : "View saved messages")))
entries.append(.action(id: id.count, section: .messages, actionType: "clearDeletedMessages" as AnyHashable, text: i18n("Settings.DeletedMessages.Clear", lang), kind: .destructive))
let saveEditHistoryTitle = (lang == "ru" ? "Сохранять историю редактирования" : "Save edit history")
let saveEditHistoryNotice = (lang == "ru"
? "Сохраняет оригинальный текст сообщений при редактировании."
: "Keeps original message text when you edit messages.")
entries.append(.toggle(id: id.count, section: .messages, settingName: .saveEditHistory, value: SGSimpleSettings.shared.saveEditHistory, text: saveEditHistoryTitle, enabled: true))
entries.append(.notice(id: id.count, section: .messages, text: saveEditHistoryNotice))
let localEditTitle = (lang == "ru" ? "Редактировать сообщения собеседника (локально)" : "Edit other's messages (local only)")
let localEditNotice = (lang == "ru"
? "В контекстном меню входящих сообщений появится «Редактировать». Изменения видны только на вашем устройстве."
: "Adds «Edit» to context menu for incoming messages. Changes are visible only on your device.")
entries.append(.toggle(id: id.count, section: .messages, settingName: .enableLocalMessageEditing, value: SGSimpleSettings.shared.enableLocalMessageEditing, text: localEditTitle, enabled: true))
entries.append(.notice(id: id.count, section: .messages, text: localEditNotice))
// MARK: Chat list / Read all
entries.append(.header(id: id.count, section: .chatList, text: i18n("READ_ALL_HEADER", lang), badge: nil))
entries.append(.action(id: id.count, section: .chatList, actionType: "markAllReadLocal" as AnyHashable, text: i18n("READ_ALL_LOCAL_TITLE", lang), kind: .generic))
entries.append(.notice(id: id.count, section: .chatList, text: i18n("READ_ALL_LOCAL_SUBTITLE", lang)))
entries.append(.action(id: id.count, section: .chatList, actionType: "markAllReadServer" as AnyHashable, text: i18n("READ_ALL_SERVER_TITLE", lang), kind: .generic))
entries.append(.notice(id: id.count, section: .chatList, text: i18n("READ_ALL_SERVER_SUBTITLE", lang)))
// MARK: Online status / Ghost mode
entries.append(.header(id: id.count, section: .onlineStatus, text: (lang == "ru" ? "ОНЛАЙН-СТАТУС" : "ONLINE STATUS"), badge: nil))
entries.append(.toggle(id: id.count, section: .onlineStatus, settingName: .disableOnlineStatus, value: SGSimpleSettings.shared.disableOnlineStatus, text: i18n("DISABLE_ONLINE_STATUS_TITLE", lang), enabled: true))
entries.append(.notice(id: id.count, section: .onlineStatus, text: i18n("DISABLE_ONLINE_STATUS_SUBTITLE", lang)))
let delaySeconds = SGSimpleSettings.shared.ghostModeMessageSendDelaySeconds
let delayLeftLabel = lang == "ru" ? "Выкл" : "Off"
let delayRightLabel = lang == "ru" ? "45 сек" : "45 sec"
let delayCenterLabels = lang == "ru" ? ["Выкл", "12 сек", "30 сек", "45 сек"] : ["Off", "12 sec", "30 sec", "45 sec"]
entries.append(.delaySecondsSlider(id: id.count, section: .onlineStatus, settingName: .ghostModeMessageSendDelay, value: delaySeconds, leftLabel: delayLeftLabel, rightLabel: delayRightLabel, centerLabels: delayCenterLabels))
let delayNotice = (lang == "ru" ? "При включённой задержке сообщения будут отправляться через выбранный интервал (12, 30 или 45 секунд). Онлайн-статус не будет отображаться во время отправки." : "When delay is enabled, messages will be sent after the selected interval (12, 30 or 45 seconds). Online status will not appear during sending.")
entries.append(.notice(id: id.count, section: .onlineStatus, text: delayNotice))
entries.append(.toggle(id: id.count, section: .onlineStatus, settingName: .disableTypingStatus, value: SGSimpleSettings.shared.disableTypingStatus, text: i18n("DISABLE_TYPING_STATUS_TITLE", lang), enabled: true))
entries.append(.notice(id: id.count, section: .onlineStatus, text: i18n("DISABLE_TYPING_STATUS_SUBTITLE", lang)))
entries.append(.toggle(id: id.count, section: .onlineStatus, settingName: .disableRecordingVideoStatus, value: SGSimpleSettings.shared.disableRecordingVideoStatus, text: i18n("DISABLE_RECORDING_VIDEO_STATUS_TITLE", lang), enabled: true))
entries.append(.notice(id: id.count, section: .onlineStatus, text: i18n("DISABLE_RECORDING_VIDEO_STATUS_SUBTITLE", lang)))
entries.append(.toggle(id: id.count, section: .onlineStatus, settingName: .disableUploadingVideoStatus, value: SGSimpleSettings.shared.disableUploadingVideoStatus, text: i18n("DISABLE_UPLOADING_VIDEO_STATUS_TITLE", lang), enabled: true))
entries.append(.notice(id: id.count, section: .onlineStatus, text: i18n("DISABLE_UPLOADING_VIDEO_STATUS_SUBTITLE", lang)))
entries.append(.toggle(id: id.count, section: .onlineStatus, settingName: .disableVCMessageRecordingStatus, value: SGSimpleSettings.shared.disableVCMessageRecordingStatus, text: i18n("DISABLE_VC_MESSAGE_RECORDING_STATUS_TITLE", lang), enabled: true))
entries.append(.notice(id: id.count, section: .onlineStatus, text: i18n("DISABLE_VC_MESSAGE_RECORDING_STATUS_SUBTITLE", lang)))
entries.append(.toggle(id: id.count, section: .onlineStatus, settingName: .disableVCMessageUploadingStatus, value: SGSimpleSettings.shared.disableVCMessageUploadingStatus, text: i18n("DISABLE_VC_MESSAGE_UPLOADING_STATUS_TITLE", lang), enabled: true))
entries.append(.notice(id: id.count, section: .onlineStatus, text: i18n("DISABLE_VC_MESSAGE_UPLOADING_STATUS_SUBTITLE", lang)))
entries.append(.toggle(id: id.count, section: .onlineStatus, settingName: .disableUploadingPhotoStatus, value: SGSimpleSettings.shared.disableUploadingPhotoStatus, text: i18n("DISABLE_UPLOADING_PHOTO_STATUS_TITLE", lang), enabled: true))
entries.append(.notice(id: id.count, section: .onlineStatus, text: i18n("DISABLE_UPLOADING_PHOTO_STATUS_SUBTITLE", lang)))
entries.append(.toggle(id: id.count, section: .onlineStatus, settingName: .disableUploadingFileStatus, value: SGSimpleSettings.shared.disableUploadingFileStatus, text: i18n("DISABLE_UPLOADING_FILE_STATUS_TITLE", lang), enabled: true))
entries.append(.notice(id: id.count, section: .onlineStatus, text: i18n("DISABLE_UPLOADING_FILE_STATUS_SUBTITLE", lang)))
entries.append(.toggle(id: id.count, section: .onlineStatus, settingName: .disableChoosingLocationStatus, value: SGSimpleSettings.shared.disableChoosingLocationStatus, text: i18n("DISABLE_CHOOSING_LOCATION_STATUS_TITLE", lang), enabled: true))
entries.append(.notice(id: id.count, section: .onlineStatus, text: i18n("DISABLE_CHOOSING_LOCATION_STATUS_SUBTITLE", lang)))
entries.append(.toggle(id: id.count, section: .onlineStatus, settingName: .disableChoosingContactStatus, value: SGSimpleSettings.shared.disableChoosingContactStatus, text: i18n("DISABLE_CHOOSING_CONTACT_TITLE", lang), enabled: true))
entries.append(.notice(id: id.count, section: .onlineStatus, text: i18n("DISABLE_CHOOSING_CONTACT_SUBTITLE", lang)))
entries.append(.toggle(id: id.count, section: .onlineStatus, settingName: .disablePlayingGameStatus, value: SGSimpleSettings.shared.disablePlayingGameStatus, text: i18n("DISABLE_PLAYING_GAME_STATUS_TITLE", lang), enabled: true))
entries.append(.notice(id: id.count, section: .onlineStatus, text: i18n("DISABLE_PLAYING_GAME_STATUS_SUBTITLE", lang)))
entries.append(.toggle(id: id.count, section: .onlineStatus, settingName: .disableRecordingRoundVideoStatus, value: SGSimpleSettings.shared.disableRecordingRoundVideoStatus, text: i18n("DISABLE_RECORDING_ROUND_VIDEO_STATUS_TITLE", lang), enabled: true))
entries.append(.notice(id: id.count, section: .onlineStatus, text: i18n("DISABLE_RECORDING_ROUND_VIDEO_STATUS_SUBTITLE", lang)))
entries.append(.toggle(id: id.count, section: .onlineStatus, settingName: .disableUploadingRoundVideoStatus, value: SGSimpleSettings.shared.disableUploadingRoundVideoStatus, text: i18n("DISABLE_UPLOADING_ROUND_VIDEO_STATUS_TITLE", lang), enabled: true))
entries.append(.toggle(id: id.count, section: .onlineStatus, settingName: .disableSpeakingInGroupCallStatus, value: SGSimpleSettings.shared.disableSpeakingInGroupCallStatus, text: i18n("DISABLE_SPEAKING_IN_GROUP_CALL_STATUS_TITLE", lang), enabled: true))
entries.append(.notice(id: id.count, section: .onlineStatus, text: i18n("DISABLE_SPEAKING_IN_GROUP_CALL_STATUS_SUBTITLE", lang)))
entries.append(.toggle(id: id.count, section: .onlineStatus, settingName: .disableChoosingStickerStatus, value: SGSimpleSettings.shared.disableChoosingStickerStatus, text: i18n("DISABLE_CHOOSING_STICKER_STATUS_TITLE", lang), enabled: true))
entries.append(.notice(id: id.count, section: .onlineStatus, text: i18n("DISABLE_CHOOSING_STICKER_STATUS_SUBTITLE", lang)))
entries.append(.toggle(id: id.count, section: .onlineStatus, settingName: .disableEmojiInteractionStatus, value: SGSimpleSettings.shared.disableEmojiInteractionStatus, text: i18n("DISABLE_EMOJI_INTERACTION_STATUS_TITLE", lang), enabled: true))
entries.append(.notice(id: id.count, section: .onlineStatus, text: i18n("DISABLE_EMOJI_INTERACTION_STATUS_SUBTITLE", lang)))
entries.append(.toggle(id: id.count, section: .onlineStatus, settingName: .disableEmojiAcknowledgementStatus, value: SGSimpleSettings.shared.disableEmojiAcknowledgementStatus, text: i18n("DISABLE_EMOJI_ACKNOWLEDGEMENT_STATUS_TITLE", lang), enabled: true))
entries.append(.notice(id: id.count, section: .onlineStatus, text: i18n("DISABLE_EMOJI_ACKNOWLEDGEMENT_STATUS_SUBTITLE", lang)))
// MARK: Read receipts
entries.append(.header(id: id.count, section: .readReceipts, text: (lang == "ru" ? "ОТЧЁТЫ О ПРОЧТЕНИИ" : "READ RECEIPTS"), badge: nil))
let disableMessageReadReceiptTitle = (lang == "ru" ? "Отчёты: сообщения" : i18n("DISABLE_MESSAGE_READ_RECEIPT_TITLE", lang))
entries.append(.toggle(id: id.count, section: .readReceipts, settingName: .disableMessageReadReceipt, value: SGSimpleSettings.shared.disableMessageReadReceipt, text: disableMessageReadReceiptTitle, enabled: true))
entries.append(.notice(id: id.count, section: .readReceipts, text: i18n("DISABLE_MESSAGE_READ_RECEIPT_SUBTITLE", lang)))
let ghostMarkReadOnReplyTitle = (lang == "ru" ? "Помечать прочитанным при ответе" : "Mark as read when replying")
let ghostMarkReadOnReplyNotice = (lang == "ru"
? "Если отключены отчёты о прочтении сообщений: при ответе на входящее оно может помечаться прочитанным на сервере. Выключите, чтобы ответ не менял статус прочтения."
: "When message read receipts are off: replying to an incoming message can still mark it read on the server. Turn off to keep replies from updating read status.")
entries.append(.toggle(id: id.count, section: .readReceipts, settingName: .ghostModeMarkReadOnReply, value: SGSimpleSettings.shared.ghostModeMarkReadOnReply, text: ghostMarkReadOnReplyTitle, enabled: true))
entries.append(.notice(id: id.count, section: .readReceipts, text: ghostMarkReadOnReplyNotice))
let disableStoryReadReceiptTitle = (lang == "ru" ? "Отчёты: истории" : i18n("DISABLE_STORY_READ_RECEIPT_TITLE", lang))
entries.append(.toggle(id: id.count, section: .readReceipts, settingName: .disableStoryReadReceipt, value: SGSimpleSettings.shared.disableStoryReadReceipt, text: disableStoryReadReceiptTitle, enabled: true))
entries.append(.notice(id: id.count, section: .readReceipts, text: i18n("DISABLE_STORY_READ_RECEIPT_SUBTITLE", lang)))
// MARK: Content / security / ads
entries.append(.header(id: id.count, section: .content, text: (lang == "ru" ? "КОНТЕНТ И БЕЗОПАСНОСТЬ" : "CONTENT & SECURITY"), badge: nil))
let disableAllAdsTitle = (lang == "ru" ? "Отключить рекламу" : i18n("DISABLE_ALL_ADS_TITLE", lang))
entries.append(.toggle(id: id.count, section: .content, settingName: .disableAllAds, value: SGSimpleSettings.shared.disableAllAds, text: disableAllAdsTitle, enabled: true))
entries.append(.notice(id: id.count, section: .content, text: i18n("DISABLE_ALL_ADS_SUBTITLE", lang)))
let hideProxySponsorTitle = (lang == "ru" ? "Скрыть спонсора прокси" : i18n("HIDE_PROXY_SPONSOR_TITLE", lang))
entries.append(.toggle(id: id.count, section: .content, settingName: .hideProxySponsor, value: SGSimpleSettings.shared.hideProxySponsor, text: hideProxySponsorTitle, enabled: true))
entries.append(.notice(id: id.count, section: .content, text: i18n("HIDE_PROXY_SPONSOR_SUBTITLE", lang)))
let enableSavingProtectedTitle = (lang == "ru" ? "Сохранять защищённый контент" : i18n("ENABLE_SAVING_PROTECTED_CONTENT_TITLE", lang))
entries.append(.toggle(id: id.count, section: .content, settingName: .enableSavingProtectedContent, value: SGSimpleSettings.shared.enableSavingProtectedContent, text: enableSavingProtectedTitle, enabled: true))
entries.append(.notice(id: id.count, section: .content, text: i18n("ENABLE_SAVING_PROTECTED_CONTENT_SUBTITLE", lang)))
let forwardRestrictedTitle = (lang == "ru" ? "Пересылать защищённые сообщения" : "Forward restricted messages")
entries.append(.toggle(id: id.count, section: .content, settingName: .forwardRestrictedAsCopy, value: SGSimpleSettings.shared.forwardRestrictedAsCopy, text: forwardRestrictedTitle, enabled: true))
let forwardRestrictedNotice = (lang == "ru" ? "Текст защищённого сообщения будет скопирован и отправлен от вашего имени." : "Text from restricted messages will be copied and sent as your own.")
entries.append(.notice(id: id.count, section: .content, text: forwardRestrictedNotice))
let enableSavingSelfDestructTitle = (lang == "ru" ? "Сохранять самоуничтож." : i18n("ENABLE_SAVING_SELF_DESTRUCTING_MESSAGES_TITLE", lang))
entries.append(.toggle(id: id.count, section: .content, settingName: .enableSavingSelfDestructingMessages, value: SGSimpleSettings.shared.enableSavingSelfDestructingMessages, text: enableSavingSelfDestructTitle, enabled: true))
entries.append(.notice(id: id.count, section: .content, text: i18n("ENABLE_SAVING_SELF_DESTRUCTING_MESSAGES_SUBTITLE", lang)))
let disableScreenshotDetectionTitle = (lang == "ru" ? "Скрыть скриншоты" : i18n("DISABLE_SCREENSHOT_DETECTION_TITLE", lang))
entries.append(.toggle(id: id.count, section: .content, settingName: .disableScreenshotDetection, value: SGSimpleSettings.shared.disableScreenshotDetection, text: disableScreenshotDetectionTitle, enabled: true))
entries.append(.notice(id: id.count, section: .content, text: i18n("DISABLE_SCREENSHOT_DETECTION_SUBTITLE", lang)))
let disableSecretBlurTitle = (lang == "ru" ? "Не размывать секретные" : i18n("DISABLE_SECRET_CHAT_BLUR_ON_SCREENSHOT_TITLE", lang))
entries.append(.toggle(id: id.count, section: .content, settingName: .disableSecretChatBlurOnScreenshot, value: SGSimpleSettings.shared.disableSecretChatBlurOnScreenshot, text: disableSecretBlurTitle, enabled: true))
entries.append(.notice(id: id.count, section: .content, text: i18n("DISABLE_SECRET_CHAT_BLUR_ON_SCREENSHOT_SUBTITLE", lang)))
// MARK: GLEGram Face blur in video messages
let faceBlurTitle = lang == "ru" ? "Скрытие лица в видеосообщениях" : "Face blur in video messages"
entries.append(.toggle(id: id.count, section: .content, settingName: .faceBlurInVideoMessages, value: SGSimpleSettings.shared.faceBlurInVideoMessages, text: faceBlurTitle, enabled: true))
let faceBlurNotice = lang == "ru" ? "При записи видеосообщения (кружка) ваше лицо будет автоматически заблюрено перед отправкой." : "Your face will be automatically blurred in video messages before sending."
entries.append(.notice(id: id.count, section: .content, text: faceBlurNotice))
// MARK: 18+ / Sensitive content (server-side)
if let contentSettingsConfiguration {
let canAdjust = contentSettingsConfiguration.canAdjustSensitiveContent
let sensitiveTitle = (lang == "ru" ? "Разрешить 18+ контент" : presentationData.strings.Settings_SensitiveContent)
let sensitiveInfo = presentationData.strings.Settings_SensitiveContentInfo
entries.append(.toggle(
id: id.count,
section: .content,
settingName: .sensitiveContentEnabled,
value: contentSettingsConfiguration.sensitiveContentEnabled,
text: sensitiveTitle,
enabled: canAdjust
))
entries.append(.notice(id: id.count, section: .content, text: canAdjust ? sensitiveInfo : (lang == "ru" ? "Сервер Telegram не разрешает менять эту настройку для данного аккаунта." : "Telegram server does not allow changing this setting for this account.")))
} else {
// Configuration not loaded yet show disabled placeholder.
let sensitiveTitle = (lang == "ru" ? "Разрешить 18+ контент" : "Sensitive content")
entries.append(.toggle(
id: id.count,
section: .content,
settingName: .sensitiveContentEnabled,
value: false,
text: sensitiveTitle,
enabled: false
))
entries.append(.notice(id: id.count, section: .content, text: (lang == "ru" ? "Загрузка настроек… (нужен доступ к серверу Telegram)" : "Loading settings… (requires Telegram server access)")))
}
// MARK: Double Bottom (hidden accounts / second passcode)
entries.append(.header(id: id.count, section: .doubleBottom, text: (lang == "ru" ? "ДВОЙНОЕ ДНО" : "DOUBLE BOTTOM"), badge: nil))
entries.append(.disclosure(id: id.count, section: .doubleBottom, link: .doubleBottomSettings, text: (lang == "ru" ? "Двойное дно" : "Double Bottom")))
entries.append(.notice(id: id.count, section: .doubleBottom, text: (lang == "ru" ? "Скрытые аккаунты и вход по паролю. Разные пароли открывают разные профили." : "Hidden accounts and passcode access. Different passwords open different profiles.")))
// MARK: Password for chats / folders
entries.append(.header(id: id.count, section: .protectedChats, text: (lang == "ru" ? "ПАРОЛЬ ДЛЯ ЧАТОВ" : "PASSWORD FOR CHATS"), badge: nil))
entries.append(.disclosure(id: id.count, section: .protectedChats, link: .protectedChatsSettings, text: (lang == "ru" ? "Пароль при заходе в чат" : "Password when entering chat")))
entries.append(.notice(id: id.count, section: .protectedChats, text: (lang == "ru" ? "Выберите чаты и/или папки, при открытии которых нужно вводить пароль (пароль Telegram или отдельный)." : "Select chats and/or folders that require a passcode to open (device passcode or a separate one).")))
// MARK: Voice Morpher (Privacy tab) ghostgram-style local processing
entries.append(.header(id: id.count, section: .voiceChanger, text: (lang == "ru" ? "СМЕНА ГОЛОСА" : "VOICE MORPHER"), badge: nil))
let vm = VoiceMorpherManager.shared
entries.append(.toggle(id: id.count, section: .voiceChanger, settingName: .voiceChangerEnabled, value: vm.isEnabled, text: (lang == "ru" ? "Изменять голос при записи" : "Change voice when recording"), enabled: true))
let ru = lang == "ru"
let displayedPreset: VoiceMorpherManager.VoicePreset = {
if !vm.isEnabled { return .disabled }
if vm.selectedPresetId == 0 { return .anonymous }
return vm.selectedPreset
}()
let presetTitle = displayedPreset.title(langIsRu: ru)
entries.append(.disclosure(id: id.count, section: .voiceChanger, link: .voiceChangerVoicePicker, text: (ru ? "Эффект: \(presetTitle)" : "Effect: \(presetTitle)")))
entries.append(.notice(id: id.count, section: .voiceChanger, text: (ru
? "Локально: OGG → эффекты iOS (тон, искажение) → снова OGG. Без серверов. Как в ghostgram iOS."
: "On-device: OGG → iOS audio effects (pitch, distortion) → OGG. No servers. Same approach as ghostgram iOS.")))
// MARK: Local premium
entries.append(.header(id: id.count, section: .localPremium, text: i18n("Settings.Other.LocalPremium", lang), badge: nil))
entries.append(.toggle(id: id.count, section: .localPremium, settingName: .enableLocalPremium, value: SGSimpleSettings.shared.enableLocalPremium, text: i18n("Settings.Other.EnableLocalPremium", lang), enabled: true))
let localPremiumNotice = lang == "ru"
? "Локально разблокирует лимиты Premium, эмодзи-статус, цвета имени и профиля в оформлении (без подписки Telegram Premium)."
: "Locally unlocks Premium limits, emoji status, and name/profile appearance colors (without a Telegram Premium subscription)."
entries.append(.notice(id: id.count, section: .localPremium, text: localPremiumNotice))
// MARK: Interface (appearance tab: only tab organizer)
entries.append(.header(id: id.count, section: .interface, text: (lang == "ru" ? "ИНТЕРФЕЙС" : "INTERFACE"), badge: nil))
entries.append(.disclosure(id: id.count, section: .interface, link: .tabOrganizer, text: (lang == "ru" ? "Органайзер таббара" : "Tab Bar Organizer")))
entries.append(.notice(id: id.count, section: .interface, text: (lang == "ru" ? "Порядок и видимость вкладок внизу экрана (Чаты, Контакты, Звонки, Настройки)." : "Order and visibility of bottom tabs (Chats, Contacts, Calls, Settings).")))
// MARK: Оформление (Appearance)
entries.append(.header(id: id.count, section: .appearance, text: (lang == "ru" ? "ОБЛОЖКА ПРОФИЛЯ" : "PROFILE COVER"), badge: nil))
entries.append(.disclosure(id: id.count, section: .appearance, link: .profileCover, text: (lang == "ru" ? "Обложка профиля" : "Profile cover")))
entries.append(.notice(id: id.count, section: .appearance, text: (lang == "ru" ? "Фото или видео вместо цвета в профиле (видно только вам)." : "Photo or video instead of color in profile (visible only to you).")))
let giftIdTitle = (lang == "ru" ? "Показывать ID подарка" : "Show gift ID")
let giftIdNotice = (lang == "ru" ? "При нажатии на информацию о подарке отображается его ID." : "When tapping gift info, its ID is shown.")
entries.append(.toggle(id: id.count, section: .appearance, settingName: .giftIdEnabled, value: SGSimpleSettings.shared.giftIdEnabled, text: giftIdTitle, enabled: true))
entries.append(.notice(id: id.count, section: .appearance, text: giftIdNotice))
entries.append(.header(id: id.count, section: .appearance, text: (lang == "ru" ? "ПОДМЕНА ПРОФИЛЯ" : "FAKE PROFILE"), badge: nil))
let fakeProfileTitle = (lang == "ru" ? "Подмена профиля" : "Fake profile")
entries.append(.toggle(id: id.count, section: .appearance, settingName: .fakeProfileEnabled, value: SGSimpleSettings.shared.fakeProfileEnabled, text: fakeProfileTitle, enabled: true))
entries.append(.disclosure(id: id.count, section: .appearance, link: .fakeProfileSettings, text: (lang == "ru" ? "Изменить" : "Change")))
entries.append(.header(id: id.count, section: .appearance, text: (lang == "ru" ? "ЗАМЕНА ШРИФТА" : "FONT REPLACEMENT"), badge: nil))
entries.append(.toggle(id: id.count, section: .appearance, settingName: .enableFontReplacement, value: SGSimpleSettings.shared.enableFontReplacement, text: (lang == "ru" ? "Замена шрифта" : "Font replacement"), enabled: true))
let fontLabelApp = SGSimpleSettings.shared.fontReplacementName.isEmpty ? (lang == "ru" ? "Системный" : "System") : SGSimpleSettings.shared.fontReplacementName
entries.append(.disclosure(id: id.count, section: .appearance, link: .fontReplacementPicker, text: (lang == "ru" ? "Шрифт" : "Font")))
entries.append(.disclosure(id: id.count, section: .appearance, link: .fontReplacementImportFile, text: (lang == "ru" ? "Загрузить из файла (.ttf)" : "Import from file (.ttf)")))
entries.append(.notice(id: id.count, section: .appearance, text: (lang == "ru" ? "Текущий: " : "Current: ") + fontLabelApp))
let boldFontLabelApp = SGSimpleSettings.shared.fontReplacementBoldName.isEmpty ? (lang == "ru" ? "Авто" : "Auto") : SGSimpleSettings.shared.fontReplacementBoldName
entries.append(.disclosure(id: id.count, section: .appearance, link: .fontReplacementBoldPicker, text: (lang == "ru" ? "Жирный шрифт" : "Bold font")))
entries.append(.disclosure(id: id.count, section: .appearance, link: .fontReplacementBoldImportFile, text: i18n("FONT_IMPORT_BOLD_FROM_FILE", lang)))
entries.append(.notice(id: id.count, section: .appearance, text: (lang == "ru" ? "Текущий: " : "Current: ") + boldFontLabelApp))
entries.append(.fontSizeMultiplierSlider(id: id.count, section: .appearance, settingName: .fontReplacementSize, value: max(50, min(150, SGSimpleSettings.shared.fontReplacementSizeMultiplier))))
entries.append(.notice(id: id.count, section: .appearance, text: (lang == "ru" ? "Размер шрифта (50–150%)." : "Font size (50150%).")))
let avatarRoundingBadge: String? = {
guard SGSimpleSettings.shared.customAvatarRoundingEnabled else {
return nil
}
let p = SGSimpleSettings.shared.avatarRoundingPercent
if p <= 0 {
return lang == "ru" ? "КВАДРАТ" : "SQUARE"
}
if p >= 100 {
return lang == "ru" ? "КРУГ" : "CIRCLE"
}
return "\(p)%"
}()
entries.append(.header(id: id.count, section: .appearance, text: (lang == "ru" ? "АВАТАРЫ" : "AVATARS"), badge: avatarRoundingBadge))
entries.append(.toggle(id: id.count, section: .appearance, settingName: .customAvatarRoundingEnabled, value: SGSimpleSettings.shared.customAvatarRoundingEnabled, text: (lang == "ru" ? "Закругление аватаров" : "Avatar rounding"), enabled: true))
entries.append(.notice(id: id.count, section: .appearance, text: (lang == "ru" ? "Ползунок ниже влияет на аватары и кольцо историй." : "The slider below affects avatars and the story ring.")))
if SGSimpleSettings.shared.customAvatarRoundingEnabled {
let square = lang == "ru" ? "Квадрат" : "Square"
let circle = lang == "ru" ? "Круг" : "Circle"
let pct = max(Int32(0), min(Int32(100), SGSimpleSettings.shared.avatarRoundingPercent))
entries.append(.percentageSlider(id: id.count, section: .appearance, settingName: .avatarRoundingPercent, value: pct, leftEdgeLabel: square, rightEdgeLabel: circle))
}
entries.append(.header(id: id.count, section: .appearance, text: (lang == "ru" ? "ГЛАВНЫЙ ЭКРАН" : "MAIN SCREEN"), badge: nil))
let selfChatMode = SGSimpleSettings.shared.selfChatTitleModeValue
entries.append(.oneFromManySelector(id: id.count, section: .appearance, settingName: .selfChatTitleMode, text: (lang == "ru" ? "Текст «Чаты» в шапке" : "«Chats» title in header"), value: glegSelfChatTitleModeLabel(selfChatMode, lang: lang), enabled: true))
entries.append(.notice(id: id.count, section: .appearance, text: (lang == "ru" ? "Заголовок над лентой историй на вкладке чатов (не чат «Избранное»)." : "Title above the stories row on the Chats tab (not Saved Messages chat).")))
entries.append(.header(id: id.count, section: .appearance, text: (lang == "ru" ? "ТЕКСТ И ЧИСЛА" : "TEXT & NUMBERS"), badge: nil))
let disableCompactNumbersTitle = (lang == "ru" ? "Полные числа вместо округления" : "Full numbers instead of rounding")
let disableCompactNumbersNotice = (lang == "ru" ? "Просмотры на постах будут показываться полным числом (например 1400 вместо 1.4K)." : "View counts on posts will show full number (e.g. 1400 instead of 1.4K).")
entries.append(.toggle(id: id.count, section: .appearance, settingName: .disableCompactNumbers, value: SGSimpleSettings.shared.disableCompactNumbers, text: disableCompactNumbersTitle, enabled: true))
entries.append(.notice(id: id.count, section: .appearance, text: disableCompactNumbersNotice))
let disableZalgoTitle = (lang == "ru" ? "Убирать символы Zalgo" : "Remove Zalgo characters")
let disableZalgoNotice = (lang == "ru" ? "Убирает искажающие текст символы Zalgo в именах и сообщениях." : "Removes Zalgo text distortion in names and messages.")
entries.append(.toggle(id: id.count, section: .appearance, settingName: .disableZalgoText, value: SGSimpleSettings.shared.disableZalgoText, text: disableZalgoTitle, enabled: true))
entries.append(.notice(id: id.count, section: .appearance, text: disableZalgoNotice))
// MARK: Other (Другие функции)
entries.append(.header(id: id.count, section: .other, text: (lang == "ru" ? "НАСТРОЙКИ GLEGRAM" : "GLEGRAM SETTINGS"), badge: nil))
entries.append(.action(id: id.count, section: .other, actionType: "glegExportSettings" as AnyHashable, text: (lang == "ru" ? "Экспортировать настройки" : "Export settings"), kind: .generic))
entries.append(.action(id: id.count, section: .other, actionType: "glegImportSettings" as AnyHashable, text: (lang == "ru" ? "Загрузить настройки" : "Import settings"), kind: .generic))
entries.append(.notice(id: id.count, section: .other, text: (lang == "ru" ? "JSON с включёнными функциями и значениями. Импорт перезаписывает совпадающие ключи." : "JSON with enabled features and values. Import overwrites matching keys.")))
entries.append(.header(id: id.count, section: .other, text: (lang == "ru" ? "ДРУГИЕ ФУНКЦИИ" : "OTHER"), badge: nil))
let chatExportTitle = (lang == "ru" ? "Экспорт чата" : "Export chat")
let chatExportNotice = (lang == "ru"
? "В профиле пользователя во вкладке «Ещё» появится пункт «Экспорт чата» — экспорт истории в JSON, TXT или HTML."
: "In the user profile under «More» a «Export chat» item will appear — export history to JSON, TXT or HTML.")
entries.append(.toggle(id: id.count, section: .other, settingName: .chatExportEnabled, value: SGSimpleSettings.shared.chatExportEnabled, text: chatExportTitle, enabled: true))
entries.append(.notice(id: id.count, section: .other, text: chatExportNotice))
entries.append(.toggle(id: id.count, section: .other, settingName: .scrollToTopButtonEnabled, value: SGSimpleSettings.shared.scrollToTopButtonEnabled, text: i18n("SCROLL_TO_TOP_TITLE", lang), enabled: true))
entries.append(.notice(id: id.count, section: .other, text: i18n("SCROLL_TO_TOP_NOTICE", lang)))
let unlimitedFavTitle = (lang == "ru" ? "Неограниченные избранные стикеры" : "Unlimited favorite stickers")
let unlimitedFavNotice = (lang == "ru" ? "Убирает ограничение на число стикеров в избранном." : "Removes the limit on favorite stickers count.")
entries.append(.toggle(id: id.count, section: .other, settingName: .unlimitedFavoriteStickers, value: SGSimpleSettings.shared.unlimitedFavoriteStickers, text: unlimitedFavTitle, enabled: true))
entries.append(.notice(id: id.count, section: .other, text: unlimitedFavNotice))
let telescopeTitle = (lang == "ru" ? "Создание видео кружков и голосовых сообщений" : "Creating video circles and voice messages")
let telescopeNotice = (lang == "ru"
? "Позволяет создавать видео кружки и голосовые сообщения из видео."
: "Allows creating video circles and voice messages from video.")
entries.append(.toggle(id: id.count, section: .other, settingName: .enableTelescope, value: SGSimpleSettings.shared.enableTelescope, text: telescopeTitle, enabled: true))
entries.append(.notice(id: id.count, section: .other, text: telescopeNotice))
let emojiDownloadTitle = (lang == "ru" ? "Скачивать эмодзи и стикеры в галерею" : "Download emoji and stickers to gallery")
let emojiDownloadNotice = (lang == "ru" ? "При зажатии эмодзи или стикера в контекстном меню появится сохранение в галерею." : "When you long-press an emoji or sticker, save to gallery appears in the context menu.")
entries.append(.toggle(id: id.count, section: .other, settingName: .emojiDownloaderEnabled, value: SGSimpleSettings.shared.emojiDownloaderEnabled, text: emojiDownloadTitle, enabled: true))
entries.append(.notice(id: id.count, section: .other, text: emojiDownloadNotice))
let feelRichTitle = (lang == "ru" ? "Локальный баланс звёзд" : "Local stars balance")
entries.append(.toggle(id: id.count, section: .other, settingName: .feelRichEnabled, value: SGSimpleSettings.shared.feelRichEnabled, text: feelRichTitle, enabled: true))
let starsAmountText: String = {
let raw = SGSimpleSettings.shared.feelRichStarsAmount
if raw.isEmpty { return "" }
let trimmed = String(raw.prefix(32))
return trimmed
}()
entries.append(.disclosure(id: id.count, section: .other, link: .feelRichAmount, text: (lang == "ru" ? "Изменить сумму" : "Change amount") + " (\(starsAmountText))"))
if GLEGramFeatures.pluginsEnabled {
let pluginItems = PluginRunner.shared.allSettingsItems()
if !pluginItems.isEmpty {
// Group by section name preserving order
var sectionOrder: [String] = []
var sectionMap: [String: [(pluginId: String, section: String, title: String, actionId: String)]] = [:]
for item in pluginItems {
let sec = item.section
if sectionMap[sec] == nil {
sectionOrder.append(sec)
sectionMap[sec] = []
}
sectionMap[sec]?.append(item)
}
for sec in sectionOrder {
entries.append(.header(id: id.count, section: .other, text: sec.uppercased(), badge: nil))
for item in sectionMap[sec] ?? [] {
entries.append(.action(id: id.count, section: .other, actionType: "plugin:\(item.pluginId):\(item.actionId)" as AnyHashable, text: item.title, kind: .generic))
}
}
}
}
// MARK: Fake Location
entries.append(.header(id: id.count, section: .fakeLocation, text: (lang == "ru" ? "ФЕЙКОВАЯ ГЕОЛОКАЦИЯ" : "FAKE LOCATION"), badge: nil))
let fakeLocationTitle = (lang == "ru" ? "Включить фейковую геолокацию" : "Enable Fake Location")
let fakeLocationNotice = (lang == "ru"
? "Подменяет ваше реальное местоположение на выбранное. Работает во всех приложениях, использующих геолокацию."
: "Replaces your real location with the selected one. Works in all apps that use location services.")
entries.append(.toggle(id: id.count, section: .fakeLocation, settingName: .fakeLocationEnabled, value: SGSimpleSettings.shared.fakeLocationEnabled, text: fakeLocationTitle, enabled: true))
entries.append(.notice(id: id.count, section: .fakeLocation, text: fakeLocationNotice))
let pickLocationTitle = (lang == "ru" ? "Выбрать местоположение" : "Pick Location")
entries.append(.disclosure(id: id.count, section: .fakeLocation, link: .fakeLocationPicker, text: pickLocationTitle))
// Show current coordinates if set
if SGSimpleSettings.shared.fakeLatitude != 0.0 && SGSimpleSettings.shared.fakeLongitude != 0.0 {
let coordsText = String(format: (lang == "ru" ? "Текущие координаты: lat: %.6f lon: %.6f" : "Current coordinates: lat: %.6f lon: %.6f"), SGSimpleSettings.shared.fakeLatitude, SGSimpleSettings.shared.fakeLongitude)
entries.append(.notice(id: id.count, section: .fakeLocation, text: coordsText))
} else {
let noCoordsText = (lang == "ru" ? "Координаты не выбраны. Нажмите 'Выбрать местоположение' для настройки." : "No coordinates selected. Tap 'Pick Location' to configure.")
entries.append(.notice(id: id.count, section: .fakeLocation, text: noCoordsText))
}
// MARK: Подглядеть онлайн (Peek online)
entries.append(.header(id: id.count, section: .onlineStatusRecording, text: (lang == "ru" ? "ПОДГЛЯДЕТЬ ОНЛАЙН" : "PEEK ONLINE"), badge: nil))
let peekOnlineTitle = (lang == "ru" ? "Включить «Подглядеть онлайн»" : "Enable «Peek online»")
let peekOnlineNotice = (lang == "ru"
? "Эмулирует возможность Premium «Время захода»: показывает последний онлайн у тех, кто не скрывал время захода, но скрыл его от вас. Пользователи с надписью «когда?» в профиле — время можно подсмотреть. Подписчикам Premium не нужно. Принцип: 1) Если аккаунтов несколько — статус может быть взят через другой аккаунт (мост). 2) Краткосрочная инверсия: на долю секунды «Видно всем» → фиксируется и показывается статус → настройки возвращаются."
: "Emulates Premium «Last seen»: shows last online for users who did not hide it from everyone but hid it from you. Users with «when?» in profile can be peeked. Not needed for Premium subscribers. How: 1) With multiple accounts, status may be fetched via another account (bridge). 2) Short inversion: «Visible to everyone» for a fraction of a second → status captured and shown → settings restored.")
entries.append(.toggle(id: id.count, section: .onlineStatusRecording, settingName: .enableOnlineStatusRecording, value: SGSimpleSettings.shared.enableOnlineStatusRecording, text: peekOnlineTitle, enabled: true))
entries.append(.notice(id: id.count, section: .onlineStatusRecording, text: peekOnlineNotice))
return filterSGItemListUIEntrires(entries: entries, by: state.searchQuery)
}
public func gleGramSettingsController(context: AccountContext) -> ViewController {
if let status = cachedGLEGramUserStatus(), !status.access.glegramTab, let promo = status.glegramPromo {
return gleGramPaywallController(context: context, promo: promo, trialAvailable: status.trialAvailable)
}
var presentControllerImpl: ((ViewController, ViewControllerPresentationArguments?) -> Void)?
var pushControllerImpl: ((ViewController) -> Void)?
#if canImport(FaceScanScreen)
var presentAgeVerificationImpl: ((@escaping () -> Void) -> Void)?
#endif
/// Monotonic tick so pushed tab `ItemListController` always gets a new combineLatest emission (Bool `true``true` was unreliable for some flows).
final class GLEGramSettingsReloadBump {
private var generation: UInt64 = 0
let promise = ValuePromise(UInt64(0), ignoreRepeated: false)
func bump() {
generation += 1
promise.set(generation)
}
}
let reloadBump = GLEGramSettingsReloadBump()
var fontNotifyWorkItem: DispatchWorkItem?
let initialState = GLEGramSettingsControllerState()
let statePromise = ValuePromise(initialState, ignoreRepeated: true)
let stateValue = Atomic(value: initialState)
let updateState: ((GLEGramSettingsControllerState) -> GLEGramSettingsControllerState) -> Void = { f in
statePromise.set(stateValue.modify { f($0) })
}
let updateSensitiveContentDisposable = MetaDisposable()
let updatedContentSettingsConfiguration = contentSettingsConfiguration(network: context.account.network)
|> map(Optional.init)
let contentSettingsConfigurationPromise = Promise<ContentSettingsConfiguration?>()
contentSettingsConfigurationPromise.set(.single(nil)
|> then(updatedContentSettingsConfiguration))
var argumentsRef: SGItemListArguments<SGBoolSetting, GLEGramSliderSetting, GLEGramOneFromManySetting, GLEGramDisclosureLink, AnyHashable>?
let arguments = SGItemListArguments<SGBoolSetting, GLEGramSliderSetting, GLEGramOneFromManySetting, GLEGramDisclosureLink, AnyHashable>(
context: context,
setBoolValue: { setting, value in
switch setting {
case .showDeletedMessages:
SGSimpleSettings.shared.showDeletedMessages = value
case .saveDeletedMessagesMedia:
SGSimpleSettings.shared.saveDeletedMessagesMedia = value
case .saveDeletedMessagesReactions:
SGSimpleSettings.shared.saveDeletedMessagesReactions = value
case .saveDeletedMessagesForBots:
SGSimpleSettings.shared.saveDeletedMessagesForBots = value
case .saveEditHistory:
SGSimpleSettings.shared.saveEditHistory = value
case .enableLocalMessageEditing:
SGSimpleSettings.shared.enableLocalMessageEditing = value
case .disableOnlineStatus:
SGSimpleSettings.shared.disableOnlineStatus = value
case .disableTypingStatus:
SGSimpleSettings.shared.disableTypingStatus = value
case .disableRecordingVideoStatus:
SGSimpleSettings.shared.disableRecordingVideoStatus = value
case .disableUploadingVideoStatus:
SGSimpleSettings.shared.disableUploadingVideoStatus = value
case .disableVCMessageRecordingStatus:
SGSimpleSettings.shared.disableVCMessageRecordingStatus = value
case .disableVCMessageUploadingStatus:
SGSimpleSettings.shared.disableVCMessageUploadingStatus = value
case .disableUploadingPhotoStatus:
SGSimpleSettings.shared.disableUploadingPhotoStatus = value
case .disableUploadingFileStatus:
SGSimpleSettings.shared.disableUploadingFileStatus = value
case .disableChoosingLocationStatus:
SGSimpleSettings.shared.disableChoosingLocationStatus = value
case .disableChoosingContactStatus:
SGSimpleSettings.shared.disableChoosingContactStatus = value
case .disablePlayingGameStatus:
SGSimpleSettings.shared.disablePlayingGameStatus = value
case .disableRecordingRoundVideoStatus:
SGSimpleSettings.shared.disableRecordingRoundVideoStatus = value
case .disableUploadingRoundVideoStatus:
SGSimpleSettings.shared.disableUploadingRoundVideoStatus = value
case .disableSpeakingInGroupCallStatus:
SGSimpleSettings.shared.disableSpeakingInGroupCallStatus = value
case .disableChoosingStickerStatus:
SGSimpleSettings.shared.disableChoosingStickerStatus = value
case .disableEmojiInteractionStatus:
SGSimpleSettings.shared.disableEmojiInteractionStatus = value
case .disableEmojiAcknowledgementStatus:
SGSimpleSettings.shared.disableEmojiAcknowledgementStatus = value
case .disableMessageReadReceipt:
SGSimpleSettings.shared.disableMessageReadReceipt = value
case .ghostModeMarkReadOnReply:
SGSimpleSettings.shared.ghostModeMarkReadOnReply = value
case .disableStoryReadReceipt:
SGSimpleSettings.shared.disableStoryReadReceipt = value
case .disableAllAds:
SGSimpleSettings.shared.disableAllAds = value
case .hideProxySponsor:
SGSimpleSettings.shared.hideProxySponsor = value
NotificationCenter.default.post(name: .sgHideProxySponsorDidChange, object: nil)
case .enableSavingProtectedContent:
SGSimpleSettings.shared.enableSavingProtectedContent = value
case .forwardRestrictedAsCopy:
SGSimpleSettings.shared.forwardRestrictedAsCopy = value
case .enableSavingSelfDestructingMessages:
SGSimpleSettings.shared.enableSavingSelfDestructingMessages = value
case .faceBlurInVideoMessages:
SGSimpleSettings.shared.faceBlurInVideoMessages = value
case .disableScreenshotDetection:
SGSimpleSettings.shared.disableScreenshotDetection = value
case .disableSecretChatBlurOnScreenshot:
SGSimpleSettings.shared.disableSecretChatBlurOnScreenshot = value
case .enableLocalPremium:
SGSimpleSettings.shared.enableLocalPremium = value
NotificationCenter.default.post(name: .sgEnableLocalPremiumDidChange, object: nil)
case .voiceChangerEnabled:
VoiceMorpherManager.shared.isEnabled = value
if value, VoiceMorpherManager.shared.selectedPresetId == 0 {
VoiceMorpherManager.shared.selectedPresetId = VoiceMorpherManager.VoicePreset.anonymous.rawValue
}
SGSimpleSettings.shared.voiceChangerEnabled = value
case .scrollToTopButtonEnabled:
SGSimpleSettings.shared.scrollToTopButtonEnabled = value
case .hideReactions:
SGSimpleSettings.shared.hideReactions = value
case .chatExportEnabled:
SGSimpleSettings.shared.chatExportEnabled = value
case .disableCompactNumbers:
SGSimpleSettings.shared.disableCompactNumbers = value
case .disableZalgoText:
SGSimpleSettings.shared.disableZalgoText = value
case .fakeLocationEnabled:
SGSimpleSettings.shared.fakeLocationEnabled = value
case .enableVideoToCircleOrVoice:
SGSimpleSettings.shared.enableVideoToCircleOrVoice = value
case .enableTelescope:
SGSimpleSettings.shared.enableTelescope = value
case .enableFontReplacement:
SGSimpleSettings.shared.enableFontReplacement = value
context.sharedContext.notifyFontSettingsChanged()
case .unlimitedFavoriteStickers:
SGSimpleSettings.shared.unlimitedFavoriteStickers = value
case .enableOnlineStatusRecording:
SGSimpleSettings.shared.enableOnlineStatusRecording = value
case .sensitiveContentEnabled:
let update = {
let _ = (contentSettingsConfigurationPromise.get()
|> take(1)
|> deliverOnMainQueue).start(next: { [weak contentSettingsConfigurationPromise] settings in
if var settings {
settings.sensitiveContentEnabled = value
contentSettingsConfigurationPromise?.set(.single(settings))
}
})
updateSensitiveContentDisposable.set(updateRemoteContentSettingsConfiguration(postbox: context.account.postbox, network: context.account.network, sensitiveContentEnabled: value).start())
}
if value {
#if canImport(FaceScanScreen)
if requireAgeVerification(context: context) {
presentAgeVerificationImpl?(update)
} else {
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
let alertController = textAlertController(
context: context,
title: presentationData.strings.SensitiveContent_Enable_Title,
text: presentationData.strings.SensitiveContent_Enable_Text,
actions: [
TextAlertAction(type: .genericAction, title: presentationData.strings.Common_Cancel, action: {}),
TextAlertAction(type: .defaultAction, title: presentationData.strings.SensitiveContent_Enable_Confirm, action: {
update()
})
]
)
presentControllerImpl?(alertController, nil)
}
#else
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
let alertController = textAlertController(
context: context,
title: presentationData.strings.SensitiveContent_Enable_Title,
text: presentationData.strings.SensitiveContent_Enable_Text,
actions: [
TextAlertAction(type: .genericAction, title: presentationData.strings.Common_Cancel, action: {}),
TextAlertAction(type: .defaultAction, title: presentationData.strings.SensitiveContent_Enable_Confirm, action: {
update()
})
]
)
presentControllerImpl?(alertController, nil)
#endif
} else {
update()
}
case .emojiDownloaderEnabled:
SGSimpleSettings.shared.emojiDownloaderEnabled = value
case .feelRichEnabled:
SGSimpleSettings.shared.feelRichEnabled = value
case .giftIdEnabled:
SGSimpleSettings.shared.giftIdEnabled = value
case .fakeProfileEnabled:
SGSimpleSettings.shared.fakeProfileEnabled = value
case .customAvatarRoundingEnabled:
SGSimpleSettings.shared.customAvatarRoundingEnabled = value
NotificationCenter.default.post(name: .sgAvatarRoundingSettingsDidChange, object: nil)
default:
break
}
reloadBump.bump()
},
updateSliderValue: { setting, value in
if case .fontReplacementSize = setting {
SGSimpleSettings.shared.fontReplacementSizeMultiplier = value
// Троттлинг: не перезагружаем список (подпись обновляется в ноде), notifyFontSettingsChanged раз в 120 мс
fontNotifyWorkItem?.cancel()
let item = DispatchWorkItem { [weak context] in
context?.sharedContext.notifyFontSettingsChanged()
}
fontNotifyWorkItem = item
DispatchQueue.main.asyncAfter(deadline: .now() + 0.12, execute: item)
// reloadPromise не вызываем SliderFontSizeMultiplierItemNode обновляет подпись локально
} else if case .ghostModeMessageSendDelay = setting {
SGSimpleSettings.shared.ghostModeMessageSendDelaySeconds = value
reloadBump.bump()
} else if case .avatarRoundingPercent = setting {
SGSimpleSettings.shared.avatarRoundingPercent = value
NotificationCenter.default.post(name: .sgAvatarRoundingSettingsDidChange, object: nil)
reloadBump.bump()
}
},
setOneFromManyValue: { setting in
if case .onlineStatusRecordingInterval = setting {
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
let lang = presentationData.strings.baseLanguageCode
let actionSheet = ActionSheetController(presentationData: presentationData)
let intervals: [Int32] = [5, 10, 15, 20, 30, 60]
var items: [ActionSheetItem] = []
for min in intervals {
let title = lang == "ru" ? "\(min) мин" : "\(min) min"
items.append(ActionSheetButtonItem(title: title, color: .accent, action: { [weak actionSheet] in
actionSheet?.dismissAnimated()
SGSimpleSettings.shared.onlineStatusRecordingIntervalMinutes = min
reloadBump.bump()
}))
}
actionSheet.setItemGroups([ActionSheetItemGroup(items: items), ActionSheetItemGroup(items: [
ActionSheetButtonItem(title: presentationData.strings.Common_Cancel, color: .accent, font: .bold, action: { [weak actionSheet] in
actionSheet?.dismissAnimated()
})
])])
presentControllerImpl?(actionSheet, ViewControllerPresentationArguments(presentationAnimation: .modalSheet))
return
}
if case .selfChatTitleMode = setting {
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
let lang = presentationData.strings.baseLanguageCode
let actionSheet = ActionSheetController(presentationData: presentationData)
var items: [ActionSheetItem] = []
for mode in SelfChatTitleMode.allCases {
items.append(ActionSheetButtonItem(title: glegSelfChatTitleModeLabel(mode, lang: lang), color: .accent, action: { [weak actionSheet] in
actionSheet?.dismissAnimated()
SGSimpleSettings.shared.selfChatTitleModeValue = mode
NotificationCenter.default.post(name: .sgSelfChatTitleSettingsDidChange, object: nil)
reloadBump.bump()
DispatchQueue.main.async {
reloadBump.bump()
}
}))
}
actionSheet.setItemGroups([ActionSheetItemGroup(items: items), ActionSheetItemGroup(items: [
ActionSheetButtonItem(title: presentationData.strings.Common_Cancel, color: .accent, font: .bold, action: { [weak actionSheet] in
actionSheet?.dismissAnimated()
})
])])
presentControllerImpl?(actionSheet, ViewControllerPresentationArguments(presentationAnimation: .modalSheet))
return
}
},
openDisclosureLink: { link in
if link == .channelLink {
let pd = context.sharedContext.currentPresentationData.with { $0 }
context.sharedContext.openExternalUrl(context: context, urlContext: .generic, url: "https://t.me/glegramios", forceExternal: true, presentationData: pd, navigationController: nil, dismissInput: {})
return
}
if link == .chatLink {
let pd = context.sharedContext.currentPresentationData.with { $0 }
context.sharedContext.openExternalUrl(context: context, urlContext: .generic, url: "https://t.me/glegramios_chat", forceExternal: true, presentationData: pd, navigationController: nil, dismissInput: {})
return
}
if link == .forumLink {
let pd = context.sharedContext.currentPresentationData.with { $0 }
context.sharedContext.openExternalUrl(context: context, urlContext: .generic, url: "https://t.me/glegram_forum", forceExternal: true, presentationData: pd, navigationController: nil, dismissInput: {})
return
}
if link == .betaChannel {
if let status = cachedGLEGramUserStatus(), let betaConfig = status.betaConfig, let url = betaConfig.channelUrl, isUrlSafeForExternalOpen(url) {
let pd = context.sharedContext.currentPresentationData.with { $0 }
context.sharedContext.openExternalUrl(context: context, urlContext: .generic, url: url, forceExternal: false, presentationData: pd, navigationController: nil, dismissInput: {})
}
return
}
if link == .voiceChangerVoicePicker {
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
let lang = presentationData.strings.baseLanguageCode
let ru = lang == "ru"
let actionSheet = ActionSheetController(presentationData: presentationData)
var items: [ActionSheetItem] = []
for preset in VoiceMorpherManager.VoicePreset.allCases where preset != .disabled {
let title = preset.title(langIsRu: ru)
let subtitle = preset.subtitle(langIsRu: ru)
items.append(ActionSheetButtonItem(title: "\(title)\(subtitle)", color: .accent, action: { [weak actionSheet] in
actionSheet?.dismissAnimated()
VoiceMorpherManager.shared.selectedPresetId = preset.rawValue
reloadBump.bump()
}))
}
actionSheet.setItemGroups([ActionSheetItemGroup(items: items), ActionSheetItemGroup(items: [
ActionSheetButtonItem(title: presentationData.strings.Common_Cancel, color: .accent, font: .bold, action: { [weak actionSheet] in
actionSheet?.dismissAnimated()
})
])])
presentControllerImpl?(actionSheet, ViewControllerPresentationArguments(presentationAnimation: .modalSheet))
return
}
if link == .fakeLocationPicker {
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
#if canImport(SGFakeLocation)
let pickerController = FakeLocationPickerController(presentationData: presentationData, onSave: {
reloadBump.bump()
})
pushControllerImpl?(pickerController)
#endif
} else if link == .appearanceTab {
pushControllerImpl?(buildGLEGramTabController(tab: .appearance, args: argumentsRef!))
} else if link == .securityTab {
pushControllerImpl?(buildGLEGramTabController(tab: .security, args: argumentsRef!))
} else if link == .otherTab {
pushControllerImpl?(buildGLEGramTabController(tab: .other, args: argumentsRef!))
} else if link == .tabOrganizer {
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
let tabOrganizerController = TabOrganizerController(context: context, presentationData: presentationData, onSave: {
reloadBump.bump()
})
pushControllerImpl?(tabOrganizerController)
} else if link == .profileCover {
pushControllerImpl?(ProfileCoverController(context: context))
} else if link == .fakeProfileSettings {
pushControllerImpl?(FakeProfileSettingsController(context: context, onSave: { reloadBump.bump() }))
} else if link == .feelRichAmount {
pushControllerImpl?(FeelRichAmountController(context: context, onSave: { reloadBump.bump() }))
} else if link == .savedDeletedMessagesList {
pushControllerImpl?(savedDeletedMessagesListController(context: context))
} else if link == .doubleBottomSettings {
pushControllerImpl?(doubleBottomSettingsController(context: context))
} else if link == .protectedChatsSettings {
pushControllerImpl?(protectedChatsSettingsController(context: context))
} else if link == .pluginsSettings, GLEGramFeatures.pluginsEnabled {
PluginRunner.shared.ensureLoaded()
pushControllerImpl?(PluginListController(context: context, onPluginsChanged: {
PluginRunner.shared.ensureLoaded()
reloadBump.bump()
}))
} else if link == .fontReplacementPicker {
let pickerController = FontReplacementPickerController(context: context, mode: .main, onSave: {
reloadBump.bump()
context.sharedContext.notifyFontSettingsChanged()
})
presentControllerImpl?(pickerController, ViewControllerPresentationArguments(presentationAnimation: .modalSheet))
} else if link == .fontReplacementBoldPicker {
let pickerController = FontReplacementPickerController(context: context, mode: .bold, onSave: {
reloadBump.bump()
context.sharedContext.notifyFontSettingsChanged()
})
presentControllerImpl?(pickerController, ViewControllerPresentationArguments(presentationAnimation: .modalSheet))
} else if link == .fontReplacementBoldImportFile {
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
let picker = legacyICloudFilePicker(
theme: presentationData.theme,
mode: .import,
documentTypes: ["public.font", "public.truetype-ttf-font", "public.opentype"],
dismissed: {},
completion: { urls in
guard let url = urls.first else { return }
_ = url.startAccessingSecurityScopedResource()
defer { url.stopAccessingSecurityScopedResource() }
if let provider = CGDataProvider(url: url as CFURL),
let cgFont = CGFont(provider),
let name = cgFont.postScriptName as String?, !name.isEmpty {
let fileManager = FileManager.default
if let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first {
let fontsDir = documentsURL.appendingPathComponent("SwiftgramFonts", isDirectory: true)
try? fileManager.createDirectory(at: fontsDir, withIntermediateDirectories: true)
let destURL = fontsDir.appendingPathComponent("bold.ttf")
try? fileManager.removeItem(at: destURL)
if (try? fileManager.copyItem(at: url, to: destURL)) != nil {
SGSimpleSettings.shared.fontReplacementBoldFilePath = destURL.path
}
}
CTFontManagerRegisterFontURLs([url] as CFArray, .process, true, nil)
SGSimpleSettings.shared.fontReplacementBoldName = name
context.sharedContext.notifyFontSettingsChanged()
reloadBump.bump()
}
}
)
presentControllerImpl?(picker, ViewControllerPresentationArguments(presentationAnimation: .modalSheet))
} else if link == .fontReplacementImportFile {
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
let picker = legacyICloudFilePicker(
theme: presentationData.theme,
mode: .import,
documentTypes: ["public.font", "public.truetype-ttf-font", "public.opentype"],
dismissed: {},
completion: { urls in
guard let url = urls.first else { return }
_ = url.startAccessingSecurityScopedResource()
defer { url.stopAccessingSecurityScopedResource() }
if let provider = CGDataProvider(url: url as CFURL),
let cgFont = CGFont(provider),
let name = cgFont.postScriptName as String?, !name.isEmpty {
let fileManager = FileManager.default
if let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first {
let fontsDir = documentsURL.appendingPathComponent("SwiftgramFonts", isDirectory: true)
try? fileManager.createDirectory(at: fontsDir, withIntermediateDirectories: true)
let destURL = fontsDir.appendingPathComponent("main.ttf")
try? fileManager.removeItem(at: destURL)
if (try? fileManager.copyItem(at: url, to: destURL)) != nil {
SGSimpleSettings.shared.fontReplacementFilePath = destURL.path
}
}
CTFontManagerRegisterFontURLs([url] as CFArray, .process, true, nil)
SGSimpleSettings.shared.fontReplacementName = name
context.sharedContext.notifyFontSettingsChanged()
reloadBump.bump()
}
}
)
presentControllerImpl?(picker, ViewControllerPresentationArguments(presentationAnimation: .modalSheet))
}
},
action: { actionType in
guard let actionString = actionType as? String else { return }
if actionString.hasPrefix("plugin:") {
guard GLEGramFeatures.pluginsEnabled else { return }
let parts = actionString.split(separator: ":", maxSplits: 2, omittingEmptySubsequences: false)
if parts.count >= 3 {
let pluginId = String(parts[1])
let actionId = String(parts[2])
PluginRunner.shared.runAction(pluginId: pluginId, actionId: actionId)
}
return
}
if actionString == "glegExportSettings" {
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
let lang = presentationData.strings.baseLanguageCode
do {
let url = try SGSimpleSettings.exportGLEGramSettingsJSONFile()
let picker = legacyICloudFilePicker(
theme: presentationData.theme,
mode: .export,
url: url,
documentTypes: ["public.json"],
dismissed: {
try? FileManager.default.removeItem(at: url)
},
completion: { _ in
try? FileManager.default.removeItem(at: url)
}
)
presentControllerImpl?(picker, ViewControllerPresentationArguments(presentationAnimation: .modalSheet))
} catch {
let alert = textAlertController(
context: context,
title: lang == "ru" ? "Ошибка" : "Error",
text: error.localizedDescription,
actions: [TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_OK, action: {})]
)
presentControllerImpl?(alert, nil)
}
return
}
if actionString == "glegImportSettings" {
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
let lang = presentationData.strings.baseLanguageCode
let picker = legacyICloudFilePicker(
theme: presentationData.theme,
mode: .import,
documentTypes: ["public.json", "public.text", "public.plain-text"],
dismissed: {},
completion: { urls in
guard let url = urls.first else { return }
let accessed = url.startAccessingSecurityScopedResource()
defer {
if accessed {
url.stopAccessingSecurityScopedResource()
}
}
guard let data = try? Data(contentsOf: url) else {
return
}
do {
let count = try SGSimpleSettings.importGLEGramSettingsJSON(data: data)
context.sharedContext.notifyFontSettingsChanged()
NotificationCenter.default.post(name: .sgAvatarRoundingSettingsDidChange, object: nil)
NotificationCenter.default.post(name: .sgSelfChatTitleSettingsDidChange, object: nil)
NotificationCenter.default.post(name: .sgPeerInfoAppearanceSettingsDidChange, object: nil)
NotificationCenter.default.post(name: .sgHideProxySponsorDidChange, object: nil)
reloadBump.bump()
let okLine = lang == "ru" ? "Импортировано ключей: \(count)" : "Imported keys: \(count)"
let restartLine = i18n("Common.RestartRequired", lang)
let okText = okLine + "\n" + restartLine
presentControllerImpl?(UndoOverlayController(
presentationData: presentationData,
content: .succeed(text: okText, timeout: 4.0, customUndoText: nil),
elevatedLayout: false,
action: { _ in return false }
), nil)
} catch {
let alert = textAlertController(
context: context,
title: lang == "ru" ? "Импорт" : "Import",
text: error.localizedDescription,
actions: [TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_OK, action: {})]
)
presentControllerImpl?(alert, nil)
}
}
)
presentControllerImpl?(picker, ViewControllerPresentationArguments(presentationAnimation: .modalSheet))
return
}
if actionString == "clearDeletedMessages" {
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
let lang = presentationData.strings.baseLanguageCode
let alertController = textAlertController(
context: context,
title: i18n("Settings.DeletedMessages.Clear.Title", lang),
text: i18n("Settings.DeletedMessages.Clear.Text", lang),
actions: [
TextAlertAction(type: .destructiveAction, title: presentationData.strings.Common_Delete, action: {
let _ = (SGDeletedMessages.clearAllDeletedMessages(postbox: context.account.postbox)
|> deliverOnMainQueue).start(next: { count in
reloadBump.bump()
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
let text: String
if count > 0 {
text = lang == "ru"
? "Удалено сообщений: \(count)"
: "Deleted messages: \(count)"
} else {
text = lang == "ru"
? "Нет сохранённых удалённых сообщений"
: "No saved deleted messages"
}
presentControllerImpl?(UndoOverlayController(
presentationData: presentationData,
content: .succeed(text: text, timeout: 3.0, customUndoText: nil),
elevatedLayout: false,
action: { _ in return false }
), nil)
})
}),
TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_Cancel, action: {})
]
)
presentControllerImpl?(alertController, nil)
}
if actionString == "markAllReadLocal" {
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
let statusController = OverlayStatusController(theme: presentationData.theme, type: .loading(cancelled: nil))
presentControllerImpl?(statusController, nil)
let markItems: [(groupId: EngineChatList.Group, filterPredicate: ChatListFilterPredicate?)] = [
(.root, nil),
(.archive, nil)
]
let _ = (context.engine.messages.markAllChatsAsReadLocallyOnly(items: markItems)
|> deliverOnMainQueue).start(completed: {
statusController.dismiss()
reloadBump.bump()
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
presentControllerImpl?(OverlayStatusController(theme: presentationData.theme, type: .success), nil)
})
}
if actionString == "markAllReadServer" {
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
let statusController = OverlayStatusController(theme: presentationData.theme, type: .loading(cancelled: nil))
presentControllerImpl?(statusController, nil)
let _ = (context.engine.messages.markAllChatsAsRead()
|> deliverOnMainQueue).start(completed: {
statusController.dismiss()
reloadBump.bump()
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
presentControllerImpl?(OverlayStatusController(theme: presentationData.theme, type: .success), nil)
})
}
},
searchInput: { searchQuery in
updateState { state in
var updatedState = state
updatedState.searchQuery = searchQuery
return updatedState
}
},
iconResolver: { ref in
guard let ref = ref else { return nil }
guard let img = UIImage(bundleImageName: ref) else { return nil }
// Иконки вкладок (GLEGramTab*) масштабируем до размера как у «Канал, Чат, Форум» (~29 pt)
return scaleImageForListIcon(img, maxSize: 29.0) ?? img
}
)
argumentsRef = arguments
func buildGLEGramTabController(tab: GLEGramTab, args: SGItemListArguments<SGBoolSetting, GLEGramSliderSetting, GLEGramOneFromManySetting, GLEGramDisclosureLink, AnyHashable>) -> ViewController {
let tabSignal = combineLatest(reloadBump.promise.get(), statePromise.get(), context.sharedContext.presentationData, contentSettingsConfigurationPromise.get())
|> map { _, state, presentationData, contentSettingsConfiguration -> (ItemListControllerState, (ItemListNodeState, SGItemListArguments<SGBoolSetting, GLEGramSliderSetting, GLEGramOneFromManySetting, GLEGramDisclosureLink, AnyHashable>)) in
let lang = presentationData.strings.baseLanguageCode
let tabTitles = lang == "ru" ? ["Оформление", "Приватность", "Другие функции"] : ["Appearance", "Privacy", "Other"]
let tabTitle = tabTitles[tab.rawValue]
var tabState = state
tabState.selectedTab = tab
let allEntries = gleGramEntries(presentationData: presentationData, contentSettingsConfiguration: contentSettingsConfiguration, state: tabState, mediaBoxBasePath: context.account.postbox.mediaBox.basePath)
let entriesFilteredByTab = gleGramEntriesFiltered(by: tab, entries: allEntries)
let entries = filterSGItemListUIEntrires(entries: entriesFilteredByTab, by: tabState.searchQuery)
let controllerState = ItemListControllerState(
presentationData: ItemListPresentationData(presentationData),
title: .text(tabTitle),
leftNavigationButton: nil,
rightNavigationButton: nil,
backNavigationButton: ItemListBackButton(title: presentationData.strings.Common_Back)
)
let listState = ItemListNodeState(
presentationData: ItemListPresentationData(presentationData),
entries: entries,
style: .blocks,
ensureVisibleItemTag: nil,
footerItem: nil,
initialScrollToItem: nil
)
return (controllerState, (listState, args))
}
let tabController = ItemListController(context: context, state: tabSignal)
tabController.navigationItem.leftBarButtonItem = makeBackBarButtonItem(presentationData: context.sharedContext.currentPresentationData.with({ $0 }), controller: tabController)
return tabController
}
let signal: Signal<(ItemListControllerState, (ItemListNodeState, SGItemListArguments<SGBoolSetting, GLEGramSliderSetting, GLEGramOneFromManySetting, GLEGramDisclosureLink, AnyHashable>)), NoError> = combineLatest(reloadBump.promise.get(), context.sharedContext.presentationData)
|> map { _, presentationData -> (ItemListControllerState, (ItemListNodeState, SGItemListArguments<SGBoolSetting, GLEGramSliderSetting, GLEGramOneFromManySetting, GLEGramDisclosureLink, AnyHashable>)) in
SGSimpleSettings.shared.currentAccountPeerId = "\(context.account.peerId.id._internalGetInt64Value())"
let controllerState = ItemListControllerState(
presentationData: ItemListPresentationData(presentationData),
title: .text("GLEGram"),
leftNavigationButton: nil,
rightNavigationButton: nil,
backNavigationButton: ItemListBackButton(title: presentationData.strings.Common_Back)
)
let entries = gleGramRootEntries(presentationData: presentationData)
let listState = ItemListNodeState(
presentationData: ItemListPresentationData(presentationData),
entries: entries,
style: .blocks,
ensureVisibleItemTag: nil,
footerItem: nil,
initialScrollToItem: nil
)
return (controllerState, (listState, arguments))
}
let controller = ItemListController(context: context, state: signal)
controller.navigationItem.leftBarButtonItem = makeBackBarButtonItem(presentationData: context.sharedContext.currentPresentationData.with({ $0 }), controller: controller)
pushControllerImpl = { [weak controller] vc in controller?.push(vc) }
presentControllerImpl = { [weak controller] c, a in
guard let controller = controller else { return }
// Present from the topmost VC in the navigation stack: when a tab controller
// is pushed, the root controller's view is removed from the hierarchy by
// UINavigationController, making its `window` nil and `present` a no-op.
if let navController = controller.navigationController as? NavigationController,
let topController = navController.viewControllers.last as? ViewController {
topController.present(c, in: .window(.root), with: a)
} else {
controller.present(c, in: .window(.root), with: a)
}
}
#if canImport(FaceScanScreen)
presentAgeVerificationImpl = { [weak controller] update in
guard let controller else {
return
}
presentAgeVerification(context: context, parentController: controller, completion: {
update()
})
}
#endif
return controller
}
```
### `Swiftgram/SGSettingsUI/Sources/SGSettingsController.swift`
```swift
// MARK: Swiftgram
import SGLogging
import SGSimpleSettings
import SGStrings
import SGAPIToken
#if canImport(SGDeletedMessages)
import SGDeletedMessages
#endif
import SGItemListUI
import Foundation
import UIKit
import Display
import SwiftSignalKit
import Postbox
import TelegramCore
import MtProtoKit
import MessageUI
import TelegramPresentationData
import TelegramUIPreferences
import ItemListUI
import PresentationDataUtils
import OverlayStatusController
import AccountContext
import AppBundle
import WebKit
import PeerNameColorScreen
import UndoUI
private enum SGControllerSection: Int32, SGItemListSection {
case search
case trending
case content
case tabs
case folders
case chatList
case profiles
case stories
case translation
case voiceMessages
case calls
case photo
case stickers
case videoNotes
case contextMenu
case accountColors
case ghostMode
case other
}
enum SGBoolSetting: String {
case hidePhoneInSettings
case showTabNames
case showContactsTab
case showCallsTab
case wideTabBar
case foldersAtBottom
case startTelescopeWithRearCam
case hideStories
case uploadSpeedBoost
case showProfileId
case warnOnStoriesOpen
case sendWithReturnKey
case rememberLastFolder
case sendLargePhotos
case storyStealthMode
case disableSwipeToRecordStory
case disableDeleteChatSwipeOption
case quickTranslateButton
case showRepostToStory
case contextShowSelectFromUser
case contextShowSaveToCloud
case contextShowHideForwardName
case contextShowRestrict
case contextShowReport
case contextShowReply
case contextShowPin
case contextShowSaveMedia
case contextShowMessageReplies
case contextShowJson
case disableScrollToNextChannel
case disableScrollToNextTopic
case disableChatSwipeOptions
case disableGalleryCamera
case disableGalleryCameraPreview
case disableSendAsButton
case disableSnapDeletionEffect
case stickerTimestamp
case hideRecordingButton
case hideTabBar
case showDC
case showCreationDate
case showRegDate
case compactChatList
case compactFolderNames
case allChatsHidden
case defaultEmojisFirst
case messageDoubleTapActionOutgoingEdit
case wideChannelPosts
case forceEmojiTab
case forceBuiltInMic
case secondsInMessages
case hideChannelBottomButton
case confirmCalls
case swipeForVideoPIP
case enableVoipTcp
case nyStyleSnow
case nyStyleLightning
case tabBarSearchEnabled
case showDeletedMessages
case saveDeletedMessagesMedia
case saveDeletedMessagesReactions
case saveDeletedMessagesForBots
case saveEditHistory
case enableLocalMessageEditing
// Ghost Mode settings
case disableOnlineStatus
case disableTypingStatus
case disableRecordingVideoStatus
case disableUploadingVideoStatus
case disableVCMessageRecordingStatus
case disableVCMessageUploadingStatus
case disableUploadingPhotoStatus
case disableUploadingFileStatus
case disableChoosingLocationStatus
case disableChoosingContactStatus
case disablePlayingGameStatus
case disableRecordingRoundVideoStatus
case disableUploadingRoundVideoStatus
case disableSpeakingInGroupCallStatus
case disableChoosingStickerStatus
case disableEmojiInteractionStatus
case disableEmojiAcknowledgementStatus
case disableMessageReadReceipt
case ghostModeMarkReadOnReply
case disableStoryReadReceipt
case disableAllAds
case hideProxySponsor
case enableSavingProtectedContent
case forwardRestrictedAsCopy
case sensitiveContentEnabled
case disableScreenshotDetection
case enableSavingSelfDestructingMessages
case disableSecretChatBlurOnScreenshot
case enableLocalPremium
case scrollToTopButtonEnabled
case fakeLocationEnabled
case enableVideoToCircleOrVoice
case enableTelescope
case enableFontReplacement
case disableCompactNumbers
case disableZalgoText
case unlimitedFavoriteStickers
case enableOnlineStatusRecording
case addMusicFromDeviceToProfile
case hideReactions
case pluginSystemEnabled
case chatExportEnabled
case emojiDownloaderEnabled
case feelRichEnabled
case giftIdEnabled
case fakeProfileEnabled
case faceBlurInVideoMessages
case customAvatarRoundingEnabled
case voiceChangerEnabled
}
private enum SGOneFromManySetting: String {
case nyStyle
case bottomTabStyle
case downloadSpeedBoost
case allChatsTitleLengthOverride
// case allChatsFolderPositionOverride
case translationBackend
case transcriptionBackend
}
private enum SGSliderSetting: String {
case accountColorsSaturation
case outgoingPhotoQuality
case stickerSize
}
private enum SGDisclosureLink: String {
case contentSettings
case languageSettings
}
private struct PeerNameColorScreenState: Equatable {
var updatedNameColor: PeerNameColor?
var updatedBackgroundEmojiId: Int64?
}
private struct SGSettingsControllerState: Equatable {
var searchQuery: String?
}
private typealias SGControllerEntry = SGItemListUIEntry<SGControllerSection, SGBoolSetting, SGSliderSetting, SGOneFromManySetting, SGDisclosureLink, AnyHashable>
private func SGControllerEntries(presentationData: PresentationData, callListSettings: CallListSettings, experimentalUISettings: ExperimentalUISettings, SGSettings: SGUISettings, appConfiguration: AppConfiguration, nameColors: PeerNameColors, state: SGSettingsControllerState) -> [SGControllerEntry] {
let lang = presentationData.strings.baseLanguageCode
let strings = presentationData.strings
let newStr = strings.Settings_New
var entries: [SGControllerEntry] = []
let id = SGItemListCounter()
entries.append(.searchInput(id: id.count, section: .search, title: NSAttributedString(string: "🔍"), text: state.searchQuery ?? "", placeholder: strings.Common_Search))
if SGSimpleSettings.shared.canUseNY {
entries.append(.header(id: id.count, section: .trending, text: i18n("Settings.NY.Header", lang), badge: newStr))
entries.append(.toggle(id: id.count, section: .trending, settingName: .nyStyleSnow, value: SGSimpleSettings.shared.nyStyle == SGSimpleSettings.NYStyle.snow.rawValue, text: i18n("Settings.NY.Style.snow", lang), enabled: true))
entries.append(.toggle(id: id.count, section: .trending, settingName: .nyStyleLightning, value: SGSimpleSettings.shared.nyStyle == SGSimpleSettings.NYStyle.lightning.rawValue, text: i18n("Settings.NY.Style.lightning", lang), enabled: true))
// entries.append(.oneFromManySelector(id: id.count, section: .trending, settingName: .nyStyle, text: i18n("Settings.NY.Style", lang), value: i18n("Settings.NY.Style.\(SGSimpleSettings.shared.nyStyle)", lang), enabled: true))
entries.append(.notice(id: id.count, section: .trending, text: i18n("Settings.NY.Notice", lang)))
} else {
id.increment(3)
}
if appConfiguration.sgWebSettings.global.canEditSettings {
entries.append(.disclosure(id: id.count, section: .content, link: .contentSettings, text: i18n("Settings.ContentSettings", lang)))
} else {
id.increment(1)
}
entries.append(.header(id: id.count, section: .tabs, text: i18n("Settings.Tabs.Header", lang), badge: nil))
entries.append(.toggle(id: id.count, section: .tabs, settingName: .hideTabBar, value: SGSimpleSettings.shared.hideTabBar, text: i18n("Settings.Tabs.HideTabBar", lang), enabled: true))
entries.append(.toggle(id: id.count, section: .tabs, settingName: .showContactsTab, value: callListSettings.showContactsTab, text: i18n("Settings.Tabs.ShowContacts", lang), enabled: !SGSimpleSettings.shared.hideTabBar))
entries.append(.toggle(id: id.count, section: .tabs, settingName: .showCallsTab, value: callListSettings.showTab, text: strings.CallSettings_TabIcon, enabled: !SGSimpleSettings.shared.hideTabBar))
entries.append(.toggle(id: id.count, section: .tabs, settingName: .showTabNames, value: SGSimpleSettings.shared.showTabNames, text: i18n("Settings.Tabs.ShowNames", lang), enabled: !SGSimpleSettings.shared.hideTabBar))
entries.append(.toggle(id: id.count, section: .tabs, settingName: .tabBarSearchEnabled, value: SGSimpleSettings.shared.tabBarSearchEnabled, text: i18n("Settings.Tabs.SearchButton", lang), enabled: !SGSimpleSettings.shared.hideTabBar))
entries.append(.toggle(id: id.count, section: .tabs, settingName: .wideTabBar, value: SGSimpleSettings.shared.wideTabBar, text: i18n("Settings.Tabs.WideTabBar", lang), enabled: !SGSimpleSettings.shared.hideTabBar))
entries.append(.notice(id: id.count, section: .tabs, text: i18n("Settings.Tabs.WideTabBar.Notice", lang)))
entries.append(.header(id: id.count, section: .folders, text: strings.Settings_ChatFolders.uppercased(), badge: nil))
entries.append(.toggle(id: id.count, section: .folders, settingName: .foldersAtBottom, value: experimentalUISettings.foldersTabAtBottom, text: i18n("Settings.Folders.BottomTab", lang), enabled: true))
entries.append(.toggle(id: id.count, section: .folders, settingName: .allChatsHidden, value: SGSimpleSettings.shared.allChatsHidden, text: i18n("Settings.Folders.AllChatsHidden", lang, strings.ChatList_Tabs_AllChats), enabled: true))
#if DEBUG
// entries.append(.oneFromManySelector(id: id.count, section: .folders, settingName: .allChatsFolderPositionOverride, text: i18n("Settings.Folders.AllChatsPlacement", lang), value: i18n("Settings.Folders.AllChatsPlacement.\(SGSimpleSettings.shared.allChatsFolderPositionOverride)", lang), enabled: true))
#endif
entries.append(.toggle(id: id.count, section: .folders, settingName: .compactFolderNames, value: SGSimpleSettings.shared.compactFolderNames, text: i18n("Settings.Folders.CompactNames", lang), enabled: true))
entries.append(.oneFromManySelector(id: id.count, section: .folders, settingName: .allChatsTitleLengthOverride, text: i18n("Settings.Folders.AllChatsTitle", lang), value: i18n("Settings.Folders.AllChatsTitle.\(SGSimpleSettings.shared.allChatsTitleLengthOverride)", lang), enabled: true))
entries.append(.toggle(id: id.count, section: .folders, settingName: .rememberLastFolder, value: SGSimpleSettings.shared.rememberLastFolder, text: i18n("Settings.Folders.RememberLast", lang), enabled: true))
entries.append(.notice(id: id.count, section: .folders, text: i18n("Settings.Folders.RememberLast.Notice", lang)))
entries.append(.header(id: id.count, section: .chatList, text: i18n("Settings.ChatList.Header", lang), badge: nil))
entries.append(.toggle(id: id.count, section: .chatList, settingName: .compactChatList, value: SGSimpleSettings.shared.compactChatList, text: i18n("Settings.CompactChatList", lang), enabled: true))
entries.append(.toggle(id: id.count, section: .chatList, settingName: .disableChatSwipeOptions, value: !SGSimpleSettings.shared.disableChatSwipeOptions, text: i18n("Settings.ChatSwipeOptions", lang), enabled: true))
entries.append(.toggle(id: id.count, section: .chatList, settingName: .disableDeleteChatSwipeOption, value: !SGSimpleSettings.shared.disableDeleteChatSwipeOption, text: i18n("Settings.DeleteChatSwipeOption", lang), enabled: !SGSimpleSettings.shared.disableChatSwipeOptions))
entries.append(.header(id: id.count, section: .profiles, text: i18n("Settings.Profiles.Header", lang), badge: nil))
entries.append(.toggle(id: id.count, section: .profiles, settingName: .showProfileId, value: SGSettings.showProfileId, text: i18n("Settings.ShowProfileID", lang), enabled: true))
entries.append(.toggle(id: id.count, section: .profiles, settingName: .showDC, value: SGSimpleSettings.shared.showDC, text: i18n("Settings.ShowDC", lang), enabled: true))
entries.append(.toggle(id: id.count, section: .profiles, settingName: .showRegDate, value: SGSimpleSettings.shared.showRegDate, text: i18n("Settings.ShowRegDate", lang), enabled: true))
entries.append(.notice(id: id.count, section: .profiles, text: i18n("Settings.ShowRegDate.Notice", lang)))
entries.append(.toggle(id: id.count, section: .profiles, settingName: .showCreationDate, value: SGSimpleSettings.shared.showCreationDate, text: i18n("Settings.ShowCreationDate", lang), enabled: true))
entries.append(.notice(id: id.count, section: .profiles, text: i18n("Settings.ShowCreationDate.Notice", lang)))
entries.append(.toggle(id: id.count, section: .profiles, settingName: .confirmCalls, value: SGSimpleSettings.shared.confirmCalls, text: i18n("Settings.CallConfirmation", lang), enabled: true))
entries.append(.notice(id: id.count, section: .profiles, text: i18n("Settings.CallConfirmation.Notice", lang)))
entries.append(.header(id: id.count, section: .stories, text: strings.AutoDownloadSettings_Stories.uppercased(), badge: nil))
entries.append(.toggle(id: id.count, section: .stories, settingName: .hideStories, value: SGSettings.hideStories, text: i18n("Settings.Stories.Hide", lang), enabled: true))
entries.append(.toggle(id: id.count, section: .stories, settingName: .disableSwipeToRecordStory, value: SGSimpleSettings.shared.disableSwipeToRecordStory, text: i18n("Settings.Stories.DisableSwipeToRecord", lang), enabled: true))
entries.append(.toggle(id: id.count, section: .stories, settingName: .warnOnStoriesOpen, value: SGSettings.warnOnStoriesOpen, text: i18n("Settings.Stories.WarnBeforeView", lang), enabled: true))
entries.append(.toggle(id: id.count, section: .stories, settingName: .showRepostToStory, value: SGSimpleSettings.shared.showRepostToStoryV2, text: strings.Share_RepostToStory.replacingOccurrences(of: "\n", with: " "), enabled: true))
if SGSimpleSettings.shared.canUseStealthMode {
entries.append(.toggle(id: id.count, section: .stories, settingName: .storyStealthMode, value: SGSimpleSettings.shared.storyStealthMode, text: strings.Story_StealthMode_Title, enabled: true))
entries.append(.notice(id: id.count, section: .stories, text: strings.Story_StealthMode_ControlText))
} else {
id.increment(2)
}
entries.append(.header(id: id.count, section: .translation, text: strings.Localization_TranslateMessages.uppercased(), badge: nil))
entries.append(.oneFromManySelector(id: id.count, section: .translation, settingName: .translationBackend, text: i18n("Settings.Translation.Backend", lang), value: i18n("Settings.Translation.Backend.\(SGSimpleSettings.shared.translationBackend)", lang), enabled: true))
if SGSimpleSettings.shared.translationBackendEnum != .gtranslate {
entries.append(.notice(id: id.count, section: .translation, text: i18n("Settings.Translation.Backend.Notice", lang, "Settings.Translation.Backend.\(SGSimpleSettings.TranslationBackend.gtranslate.rawValue)".i18n(lang))))
} else {
id.increment(1)
}
entries.append(.toggle(id: id.count, section: .translation, settingName: .quickTranslateButton, value: SGSimpleSettings.shared.quickTranslateButton, text: i18n("Settings.Translation.QuickTranslateButton", lang), enabled: true))
entries.append(.disclosure(id: id.count, section: .translation, link: .languageSettings, text: strings.Localization_TranslateEntireChat))
entries.append(.notice(id: id.count, section: .translation, text: i18n("Common.NoTelegramPremiumNeeded", lang, strings.Settings_Premium)))
entries.append(.header(id: id.count, section: .voiceMessages, text: "Settings.Transcription.Header".i18n(lang), badge: nil))
entries.append(.oneFromManySelector(id: id.count, section: .voiceMessages, settingName: .transcriptionBackend, text: i18n("Settings.Transcription.Backend", lang), value: i18n("Settings.Transcription.Backend.\(SGSimpleSettings.shared.transcriptionBackend)", lang), enabled: true))
if SGSimpleSettings.shared.transcriptionBackendEnum != .apple {
entries.append(.notice(id: id.count, section: .voiceMessages, text: i18n("Settings.Transcription.Backend.Notice", lang, "Settings.Transcription.Backend.\(SGSimpleSettings.TranscriptionBackend.apple.rawValue)".i18n(lang))))
} else {
id.increment(1)
}
entries.append(.header(id: id.count, section: .voiceMessages, text: strings.Privacy_VoiceMessages.uppercased(), badge: nil))
entries.append(.toggle(id: id.count, section: .voiceMessages, settingName: .forceBuiltInMic, value: SGSimpleSettings.shared.forceBuiltInMic, text: i18n("Settings.forceBuiltInMic", lang), enabled: true))
entries.append(.notice(id: id.count, section: .voiceMessages, text: i18n("Settings.forceBuiltInMic.Notice", lang)))
entries.append(.header(id: id.count, section: .calls, text: strings.Calls_TabTitle.uppercased(), badge: nil))
entries.append(.toggle(id: id.count, section: .calls, settingName: .enableVoipTcp, value: experimentalUISettings.enableVoipTcp, text: "Force TCP", enabled: true))
entries.append(.notice(id: id.count, section: .calls, text: "Common.KnowWhatYouDo".i18n(lang)))
entries.append(.header(id: id.count, section: .photo, text: strings.NetworkUsageSettings_MediaImageDataSection, badge: nil))
entries.append(.header(id: id.count, section: .photo, text: strings.PhotoEditor_QualityTool.uppercased(), badge: nil))
entries.append(.percentageSlider(id: id.count, section: .photo, settingName: .outgoingPhotoQuality, value: SGSimpleSettings.shared.outgoingPhotoQuality, leftEdgeLabel: nil, rightEdgeLabel: nil))
entries.append(.notice(id: id.count, section: .photo, text: i18n("Settings.Photo.Quality.Notice", lang)))
entries.append(.toggle(id: id.count, section: .photo, settingName: .sendLargePhotos, value: SGSimpleSettings.shared.sendLargePhotos, text: i18n("Settings.Photo.SendLarge", lang), enabled: true))
entries.append(.notice(id: id.count, section: .photo, text: i18n("Settings.Photo.SendLarge.Notice", lang)))
entries.append(.header(id: id.count, section: .stickers, text: strings.StickerPacksSettings_Title.uppercased(), badge: nil))
entries.append(.header(id: id.count, section: .stickers, text: i18n("Settings.Stickers.Size", lang), badge: nil))
entries.append(.percentageSlider(id: id.count, section: .stickers, settingName: .stickerSize, value: SGSimpleSettings.shared.stickerSize, leftEdgeLabel: nil, rightEdgeLabel: nil))
entries.append(.toggle(id: id.count, section: .stickers, settingName: .stickerTimestamp, value: SGSimpleSettings.shared.stickerTimestamp, text: i18n("Settings.Stickers.Timestamp", lang), enabled: true))
entries.append(.header(id: id.count, section: .videoNotes, text: i18n("Settings.VideoNotes.Header", lang), badge: nil))
entries.append(.toggle(id: id.count, section: .videoNotes, settingName: .startTelescopeWithRearCam, value: SGSimpleSettings.shared.startTelescopeWithRearCam, text: i18n("Settings.VideoNotes.StartWithRearCam", lang), enabled: true))
entries.append(.header(id: id.count, section: .contextMenu, text: i18n("Settings.ContextMenu", lang), badge: nil))
entries.append(.notice(id: id.count, section: .contextMenu, text: i18n("Settings.ContextMenu.Notice", lang)))
entries.append(.toggle(id: id.count, section: .contextMenu, settingName: .contextShowSaveToCloud, value: SGSimpleSettings.shared.contextShowSaveToCloud, text: i18n("ContextMenu.SaveToCloud", lang), enabled: true))
entries.append(.toggle(id: id.count, section: .contextMenu, settingName: .contextShowHideForwardName, value: SGSimpleSettings.shared.contextShowHideForwardName, text: strings.Conversation_ForwardOptions_HideSendersNames, enabled: true))
entries.append(.toggle(id: id.count, section: .contextMenu, settingName: .contextShowSelectFromUser, value: SGSimpleSettings.shared.contextShowSelectFromUser, text: i18n("ContextMenu.SelectFromUser", lang), enabled: true))
entries.append(.toggle(id: id.count, section: .contextMenu, settingName: .contextShowRestrict, value: SGSimpleSettings.shared.contextShowRestrict, text: strings.Conversation_ContextMenuBan, enabled: true))
entries.append(.toggle(id: id.count, section: .contextMenu, settingName: .contextShowReport, value: SGSimpleSettings.shared.contextShowReport, text: strings.Conversation_ContextMenuReport, enabled: true))
entries.append(.toggle(id: id.count, section: .contextMenu, settingName: .contextShowReply, value: SGSimpleSettings.shared.contextShowReply, text: strings.Conversation_ContextMenuReply, enabled: true))
entries.append(.toggle(id: id.count, section: .contextMenu, settingName: .contextShowPin, value: SGSimpleSettings.shared.contextShowPin, text: strings.Conversation_Pin, enabled: true))
entries.append(.toggle(id: id.count, section: .contextMenu, settingName: .contextShowSaveMedia, value: SGSimpleSettings.shared.contextShowSaveMedia, text: strings.Conversation_SaveToFiles, enabled: true))
entries.append(.toggle(id: id.count, section: .contextMenu, settingName: .contextShowMessageReplies, value: SGSimpleSettings.shared.contextShowMessageReplies, text: strings.Conversation_ContextViewThread, enabled: true))
entries.append(.toggle(id: id.count, section: .contextMenu, settingName: .contextShowJson, value: SGSimpleSettings.shared.contextShowJson, text: "JSON", enabled: true))
/* entries.append(.toggle(id: id.count, section: .contextMenu, settingName: .contextShowRestrict, value: SGSimpleSettings.shared.contextShowRestrict, text: strings.Conversation_ContextMenuBan)) */
entries.append(.header(id: id.count, section: .accountColors, text: i18n("Settings.CustomColors.Header", lang), badge: nil))
entries.append(.header(id: id.count, section: .accountColors, text: i18n("Settings.CustomColors.Saturation", lang), badge: nil))
let accountColorSaturation = SGSimpleSettings.shared.accountColorsSaturation
entries.append(.percentageSlider(id: id.count, section: .accountColors, settingName: .accountColorsSaturation, value: accountColorSaturation, leftEdgeLabel: nil, rightEdgeLabel: nil))
// let nameColor: PeerNameColor
// if let updatedNameColor = state.updatedNameColor {
// nameColor = updatedNameColor
// } else {
// nameColor = .blue
// }
// let _ = nameColors.get(nameColor, dark: presentationData.theme.overallDarkAppearance)
// entries.append(.peerColorPicker(id: entries.count, section: .other,
// colors: nameColors,
// currentColor: nameColor, // TODO: PeerNameColor(rawValue: <#T##Int32#>)
// currentSaturation: accountColorSaturation
// ))
if accountColorSaturation == 0 {
id.increment(100)
entries.append(.peerColorDisclosurePreview(id: id.count, section: .accountColors, name: "\(strings.UserInfo_FirstNamePlaceholder) \(strings.UserInfo_LastNamePlaceholder)", color: presentationData.theme.chat.message.incoming.accentTextColor))
} else {
id.increment(200)
for index in nameColors.displayOrder.prefix(3) {
let color: PeerNameColor = PeerNameColor(rawValue: index)
let colors = nameColors.get(color, dark: presentationData.theme.overallDarkAppearance)
entries.append(.peerColorDisclosurePreview(id: id.count, section: .accountColors, name: "\(strings.UserInfo_FirstNamePlaceholder) \(strings.UserInfo_LastNamePlaceholder)", color: colors.main))
}
}
entries.append(.notice(id: id.count, section: .accountColors, text: i18n("Settings.CustomColors.Saturation.Notice", lang)))
id.increment(10000)
entries.append(.header(id: id.count, section: .other, text: strings.Appearance_Other.uppercased(), badge: nil))
entries.append(.toggle(id: id.count, section: .other, settingName: .swipeForVideoPIP, value: SGSimpleSettings.shared.videoPIPSwipeDirection == SGSimpleSettings.VideoPIPSwipeDirection.up.rawValue, text: i18n("Settings.swipeForVideoPIP", lang), enabled: true))
entries.append(.notice(id: id.count, section: .other, text: i18n("Settings.swipeForVideoPIP.Notice", lang)))
entries.append(.toggle(id: id.count, section: .other, settingName: .hideChannelBottomButton, value: !SGSimpleSettings.shared.hideChannelBottomButton, text: i18n("Settings.showChannelBottomButton", lang), enabled: true))
entries.append(.toggle(id: id.count, section: .other, settingName: .wideChannelPosts, value: SGSimpleSettings.shared.wideChannelPosts, text: i18n("Settings.wideChannelPosts", lang), enabled: true))
entries.append(.toggle(id: id.count, section: .other, settingName: .secondsInMessages, value: SGSimpleSettings.shared.secondsInMessages, text: i18n("Settings.secondsInMessages", lang), enabled: true))
entries.append(.toggle(id: id.count, section: .other, settingName: .messageDoubleTapActionOutgoingEdit, value: SGSimpleSettings.shared.messageDoubleTapActionOutgoing == SGSimpleSettings.MessageDoubleTapAction.edit.rawValue, text: i18n("Settings.messageDoubleTapActionOutgoingEdit", lang), enabled: true))
entries.append(.toggle(id: id.count, section: .other, settingName: .hideRecordingButton, value: !SGSimpleSettings.shared.hideRecordingButton, text: i18n("Settings.RecordingButton", lang), enabled: true))
entries.append(.toggle(id: id.count, section: .other, settingName: .disableSnapDeletionEffect, value: !SGSimpleSettings.shared.disableSnapDeletionEffect, text: i18n("Settings.SnapDeletionEffect", lang), enabled: true))
entries.append(.toggle(id: id.count, section: .other, settingName: .disableSendAsButton, value: !SGSimpleSettings.shared.disableSendAsButton, text: i18n("Settings.SendAsButton", lang, strings.Conversation_SendMesageAs), enabled: true))
entries.append(.toggle(id: id.count, section: .other, settingName: .disableGalleryCamera, value: !SGSimpleSettings.shared.disableGalleryCamera, text: i18n("Settings.GalleryCamera", lang), enabled: true))
entries.append(.toggle(id: id.count, section: .other, settingName: .disableGalleryCameraPreview, value: !SGSimpleSettings.shared.disableGalleryCameraPreview, text: i18n("Settings.GalleryCameraPreview", lang), enabled: !SGSimpleSettings.shared.disableGalleryCamera))
entries.append(.toggle(id: id.count, section: .other, settingName: .disableScrollToNextChannel, value: !SGSimpleSettings.shared.disableScrollToNextChannel, text: i18n("Settings.PullToNextChannel", lang), enabled: true))
entries.append(.toggle(id: id.count, section: .other, settingName: .disableScrollToNextTopic, value: !SGSimpleSettings.shared.disableScrollToNextTopic, text: i18n("Settings.PullToNextTopic", lang), enabled: true))
entries.append(.toggle(id: id.count, section: .other, settingName: .hideReactions, value: SGSimpleSettings.shared.hideReactions, text: i18n("Settings.HideReactions", lang), enabled: true))
entries.append(.toggle(id: id.count, section: .other, settingName: .uploadSpeedBoost, value: SGSimpleSettings.shared.uploadSpeedBoost, text: i18n("Settings.UploadsBoost", lang), enabled: true))
entries.append(.oneFromManySelector(id: id.count, section: .other, settingName: .downloadSpeedBoost, text: i18n("Settings.DownloadsBoost", lang), value: i18n("Settings.DownloadsBoost.\(SGSimpleSettings.shared.downloadSpeedBoost)", lang), enabled: true))
entries.append(.notice(id: id.count, section: .other, text: i18n("Settings.DownloadsBoost.Notice", lang)))
entries.append(.toggle(id: id.count, section: .other, settingName: .sendWithReturnKey, value: SGSettings.sendWithReturnKey, text: i18n("Settings.SendWithReturnKey", lang), enabled: true))
entries.append(.toggle(id: id.count, section: .other, settingName: .forceEmojiTab, value: SGSimpleSettings.shared.forceEmojiTab, text: i18n("Settings.ForceEmojiTab", lang), enabled: true))
entries.append(.toggle(id: id.count, section: .other, settingName: .defaultEmojisFirst, value: SGSimpleSettings.shared.defaultEmojisFirst, text: i18n("Settings.DefaultEmojisFirst", lang), enabled: true))
entries.append(.notice(id: id.count, section: .other, text: i18n("Settings.DefaultEmojisFirst.Notice", lang)))
entries.append(.toggle(id: id.count, section: .other, settingName: .hidePhoneInSettings, value: SGSimpleSettings.shared.hidePhoneInSettings, text: i18n("Settings.HidePhoneInSettingsUI", lang), enabled: true))
entries.append(.notice(id: id.count, section: .other, text: i18n("Settings.HidePhoneInSettingsUI.Notice", lang)))
// NOTE: Swiftgram-specific privacy/content toggles were moved to GLEGram.
return filterSGItemListUIEntrires(entries: entries, by: state.searchQuery)
}
public func sgSettingsController(context: AccountContext/*, focusOnItemTag: Int? = nil*/) -> ViewController {
var presentControllerImpl: ((ViewController, ViewControllerPresentationArguments?) -> Void)?
var pushControllerImpl: ((ViewController) -> Void)?
// var getRootControllerImpl: (() -> UIViewController?)?
// var getNavigationControllerImpl: (() -> NavigationController?)?
var askForRestart: (() -> Void)?
let initialState = SGSettingsControllerState()
let statePromise = ValuePromise(initialState, ignoreRepeated: true)
let stateValue = Atomic(value: initialState)
let updateState: ((SGSettingsControllerState) -> SGSettingsControllerState) -> Void = { f in
statePromise.set(stateValue.modify { f($0) })
}
// let sliderPromise = ValuePromise(SGSimpleSettings.shared.accountColorsSaturation, ignoreRepeated: true)
// let sliderStateValue = Atomic(value: SGSimpleSettings.shared.accountColorsSaturation)
// let _: ((Int32) -> Int32) -> Void = { f in
// sliderPromise.set(sliderStateValue.modify( {f($0)}))
// }
let simplePromise = ValuePromise(true, ignoreRepeated: false)
let arguments = SGItemListArguments<SGBoolSetting, SGSliderSetting, SGOneFromManySetting, SGDisclosureLink, AnyHashable>(
context: context,
/*updatePeerColor: { color in
updateState { state in
var updatedState = state
updatedState.updatedNameColor = color
return updatedState
}
},*/ setBoolValue: { setting, value in
switch setting {
case .hidePhoneInSettings:
SGSimpleSettings.shared.hidePhoneInSettings = value
askForRestart?()
case .showTabNames:
SGSimpleSettings.shared.showTabNames = value
askForRestart?()
case .showContactsTab:
let _ = (
updateCallListSettingsInteractively(
accountManager: context.sharedContext.accountManager, { $0.withUpdatedShowContactsTab(value) }
)
).start()
case .showCallsTab:
let _ = (
updateCallListSettingsInteractively(
accountManager: context.sharedContext.accountManager, { $0.withUpdatedShowTab(value) }
)
).start()
case .tabBarSearchEnabled:
SGSimpleSettings.shared.tabBarSearchEnabled = value
case .wideTabBar:
SGSimpleSettings.shared.wideTabBar = value
askForRestart?()
case .foldersAtBottom:
let _ = (
updateExperimentalUISettingsInteractively(accountManager: context.sharedContext.accountManager, { settings in
var settings = settings
settings.foldersTabAtBottom = value
return settings
}
)
).start()
case .startTelescopeWithRearCam:
SGSimpleSettings.shared.startTelescopeWithRearCam = value
case .hideStories:
let _ = (
updateSGUISettings(engine: context.engine, { settings in
var settings = settings
settings.hideStories = value
return settings
})
).start()
case .showProfileId:
let _ = (
updateSGUISettings(engine: context.engine, { settings in
var settings = settings
settings.showProfileId = value
return settings
})
).start()
case .warnOnStoriesOpen:
let _ = (
updateSGUISettings(engine: context.engine, { settings in
var settings = settings
settings.warnOnStoriesOpen = value
return settings
})
).start()
case .sendWithReturnKey:
let _ = (
updateSGUISettings(engine: context.engine, { settings in
var settings = settings
settings.sendWithReturnKey = value
return settings
})
).start()
case .rememberLastFolder:
SGSimpleSettings.shared.rememberLastFolder = value
case .sendLargePhotos:
SGSimpleSettings.shared.sendLargePhotos = value
case .storyStealthMode:
SGSimpleSettings.shared.storyStealthMode = value
case .disableSwipeToRecordStory:
SGSimpleSettings.shared.disableSwipeToRecordStory = value
case .quickTranslateButton:
SGSimpleSettings.shared.quickTranslateButton = value
case .uploadSpeedBoost:
SGSimpleSettings.shared.uploadSpeedBoost = value
case .unlimitedFavoriteStickers:
SGSimpleSettings.shared.unlimitedFavoriteStickers = value
case .faceBlurInVideoMessages:
SGSimpleSettings.shared.faceBlurInVideoMessages = value
case .customAvatarRoundingEnabled:
SGSimpleSettings.shared.customAvatarRoundingEnabled = value
NotificationCenter.default.post(name: .sgAvatarRoundingSettingsDidChange, object: nil)
case .enableOnlineStatusRecording:
SGSimpleSettings.shared.enableOnlineStatusRecording = value
case .showRepostToStory:
SGSimpleSettings.shared.showRepostToStoryV2 = value
case .contextShowSelectFromUser:
SGSimpleSettings.shared.contextShowSelectFromUser = value
case .contextShowSaveToCloud:
SGSimpleSettings.shared.contextShowSaveToCloud = value
case .contextShowRestrict:
SGSimpleSettings.shared.contextShowRestrict = value
case .contextShowHideForwardName:
SGSimpleSettings.shared.contextShowHideForwardName = value
case .addMusicFromDeviceToProfile:
SGSimpleSettings.shared.addMusicFromDeviceToProfile = value
case .hideReactions:
SGSimpleSettings.shared.hideReactions = value
case .pluginSystemEnabled:
guard GLEGramFeatures.pluginsEnabled else { return }
SGSimpleSettings.shared.pluginSystemEnabled = value
if value {
PluginRunner.shared.ensureLoaded()
}
askForRestart?()
case .chatExportEnabled:
SGSimpleSettings.shared.chatExportEnabled = value
case .disableScrollToNextChannel:
SGSimpleSettings.shared.disableScrollToNextChannel = !value
case .disableScrollToNextTopic:
SGSimpleSettings.shared.disableScrollToNextTopic = !value
case .disableChatSwipeOptions:
SGSimpleSettings.shared.disableChatSwipeOptions = !value
simplePromise.set(true) // Trigger update for 'enabled' field of other toggles
askForRestart?()
case .disableDeleteChatSwipeOption:
SGSimpleSettings.shared.disableDeleteChatSwipeOption = !value
askForRestart?()
case .disableGalleryCamera:
SGSimpleSettings.shared.disableGalleryCamera = !value
simplePromise.set(true)
case .disableGalleryCameraPreview:
SGSimpleSettings.shared.disableGalleryCameraPreview = !value
case .disableSendAsButton:
SGSimpleSettings.shared.disableSendAsButton = !value
case .disableSnapDeletionEffect:
SGSimpleSettings.shared.disableSnapDeletionEffect = !value
case .contextShowReport:
SGSimpleSettings.shared.contextShowReport = value
case .contextShowReply:
SGSimpleSettings.shared.contextShowReply = value
case .contextShowPin:
SGSimpleSettings.shared.contextShowPin = value
case .contextShowSaveMedia:
SGSimpleSettings.shared.contextShowSaveMedia = value
case .contextShowMessageReplies:
SGSimpleSettings.shared.contextShowMessageReplies = value
case .stickerTimestamp:
SGSimpleSettings.shared.stickerTimestamp = value
case .contextShowJson:
SGSimpleSettings.shared.contextShowJson = value
case .hideRecordingButton:
SGSimpleSettings.shared.hideRecordingButton = !value
case .hideTabBar:
SGSimpleSettings.shared.hideTabBar = value
simplePromise.set(true) // Trigger update for 'enabled' field of other toggles
askForRestart?()
case .showDC:
SGSimpleSettings.shared.showDC = value
case .showCreationDate:
SGSimpleSettings.shared.showCreationDate = value
case .showRegDate:
SGSimpleSettings.shared.showRegDate = value
case .compactChatList:
SGSimpleSettings.shared.compactChatList = value
askForRestart?()
case .compactFolderNames:
SGSimpleSettings.shared.compactFolderNames = value
askForRestart?()
case .allChatsHidden:
SGSimpleSettings.shared.allChatsHidden = value
askForRestart?()
case .defaultEmojisFirst:
SGSimpleSettings.shared.defaultEmojisFirst = value
case .messageDoubleTapActionOutgoingEdit:
SGSimpleSettings.shared.messageDoubleTapActionOutgoing = value ? SGSimpleSettings.MessageDoubleTapAction.edit.rawValue : SGSimpleSettings.MessageDoubleTapAction.default.rawValue
case .wideChannelPosts:
SGSimpleSettings.shared.wideChannelPosts = value
case .forceEmojiTab:
SGSimpleSettings.shared.forceEmojiTab = value
case .forceBuiltInMic:
SGSimpleSettings.shared.forceBuiltInMic = value
case .hideChannelBottomButton:
SGSimpleSettings.shared.hideChannelBottomButton = !value
case .secondsInMessages:
SGSimpleSettings.shared.secondsInMessages = value
case .confirmCalls:
SGSimpleSettings.shared.confirmCalls = value
case .swipeForVideoPIP:
SGSimpleSettings.shared.videoPIPSwipeDirection = value ? SGSimpleSettings.VideoPIPSwipeDirection.up.rawValue : SGSimpleSettings.VideoPIPSwipeDirection.none.rawValue
case .enableVoipTcp:
let _ = (
updateExperimentalUISettingsInteractively(accountManager: context.sharedContext.accountManager, { settings in
var settings = settings
settings.enableVoipTcp = value
return settings
}
)
).start()
case .nyStyleSnow:
SGSimpleSettings.shared.nyStyle = value ? SGSimpleSettings.NYStyle.snow.rawValue : SGSimpleSettings.NYStyle.default.rawValue
simplePromise.set(true) // Trigger update for 'enabled' field of other toggles
case .nyStyleLightning:
SGSimpleSettings.shared.nyStyle = value ? SGSimpleSettings.NYStyle.lightning.rawValue : SGSimpleSettings.NYStyle.default.rawValue
simplePromise.set(true) // Trigger update for 'enabled' field of other toggles
case .showDeletedMessages:
SGSimpleSettings.shared.showDeletedMessages = value
case .saveDeletedMessagesMedia:
SGSimpleSettings.shared.saveDeletedMessagesMedia = value
case .saveDeletedMessagesReactions:
SGSimpleSettings.shared.saveDeletedMessagesReactions = value
case .saveDeletedMessagesForBots:
SGSimpleSettings.shared.saveDeletedMessagesForBots = value
case .saveEditHistory:
SGSimpleSettings.shared.saveEditHistory = value
case .enableLocalMessageEditing:
SGSimpleSettings.shared.enableLocalMessageEditing = value
case .enableFontReplacement:
SGSimpleSettings.shared.enableFontReplacement = value
case .disableCompactNumbers:
SGSimpleSettings.shared.disableCompactNumbers = value
case .disableZalgoText:
SGSimpleSettings.shared.disableZalgoText = value
// Ghost Mode settings
case .disableOnlineStatus:
SGSimpleSettings.shared.disableOnlineStatus = value
case .disableTypingStatus:
SGSimpleSettings.shared.disableTypingStatus = value
case .disableRecordingVideoStatus:
SGSimpleSettings.shared.disableRecordingVideoStatus = value
case .disableUploadingVideoStatus:
SGSimpleSettings.shared.disableUploadingVideoStatus = value
case .disableVCMessageRecordingStatus:
SGSimpleSettings.shared.disableVCMessageRecordingStatus = value
case .disableVCMessageUploadingStatus:
SGSimpleSettings.shared.disableVCMessageUploadingStatus = value
case .disableUploadingPhotoStatus:
SGSimpleSettings.shared.disableUploadingPhotoStatus = value
case .disableUploadingFileStatus:
SGSimpleSettings.shared.disableUploadingFileStatus = value
case .disableChoosingLocationStatus:
SGSimpleSettings.shared.disableChoosingLocationStatus = value
case .disableChoosingContactStatus:
SGSimpleSettings.shared.disableChoosingContactStatus = value
case .disablePlayingGameStatus:
SGSimpleSettings.shared.disablePlayingGameStatus = value
case .disableRecordingRoundVideoStatus:
SGSimpleSettings.shared.disableRecordingRoundVideoStatus = value
case .disableUploadingRoundVideoStatus:
SGSimpleSettings.shared.disableUploadingRoundVideoStatus = value
case .disableSpeakingInGroupCallStatus:
SGSimpleSettings.shared.disableSpeakingInGroupCallStatus = value
case .disableChoosingStickerStatus:
SGSimpleSettings.shared.disableChoosingStickerStatus = value
case .disableEmojiInteractionStatus:
SGSimpleSettings.shared.disableEmojiInteractionStatus = value
case .disableEmojiAcknowledgementStatus:
SGSimpleSettings.shared.disableEmojiAcknowledgementStatus = value
case .disableMessageReadReceipt:
SGSimpleSettings.shared.disableMessageReadReceipt = value
case .ghostModeMarkReadOnReply:
SGSimpleSettings.shared.ghostModeMarkReadOnReply = value
case .disableStoryReadReceipt:
SGSimpleSettings.shared.disableStoryReadReceipt = value
case .disableAllAds:
SGSimpleSettings.shared.disableAllAds = value
case .hideProxySponsor:
SGSimpleSettings.shared.hideProxySponsor = value
NotificationCenter.default.post(name: .sgHideProxySponsorDidChange, object: nil)
case .enableSavingProtectedContent:
SGSimpleSettings.shared.enableSavingProtectedContent = value
case .forwardRestrictedAsCopy:
SGSimpleSettings.shared.forwardRestrictedAsCopy = value
case .disableScreenshotDetection:
SGSimpleSettings.shared.disableScreenshotDetection = value
case .enableSavingSelfDestructingMessages:
SGSimpleSettings.shared.enableSavingSelfDestructingMessages = value
case .disableSecretChatBlurOnScreenshot:
SGSimpleSettings.shared.disableSecretChatBlurOnScreenshot = value
case .enableLocalPremium:
SGSimpleSettings.shared.enableLocalPremium = value
NotificationCenter.default.post(name: .sgEnableLocalPremiumDidChange, object: nil)
case .voiceChangerEnabled:
VoiceMorpherManager.shared.isEnabled = value
if value, VoiceMorpherManager.shared.selectedPresetId == 0 {
VoiceMorpherManager.shared.selectedPresetId = VoiceMorpherManager.VoicePreset.anonymous.rawValue
}
SGSimpleSettings.shared.voiceChangerEnabled = value
case .sensitiveContentEnabled:
// Intentionally not handled here.
// This setting lives in GLEGram and is applied via Telegram server-side content settings.
break
case .scrollToTopButtonEnabled:
SGSimpleSettings.shared.scrollToTopButtonEnabled = value
case .fakeLocationEnabled:
SGSimpleSettings.shared.fakeLocationEnabled = value
case .enableVideoToCircleOrVoice:
SGSimpleSettings.shared.enableVideoToCircleOrVoice = value
case .enableTelescope:
SGSimpleSettings.shared.enableTelescope = value
case .emojiDownloaderEnabled:
SGSimpleSettings.shared.emojiDownloaderEnabled = value
case .feelRichEnabled:
SGSimpleSettings.shared.feelRichEnabled = value
case .giftIdEnabled:
SGSimpleSettings.shared.giftIdEnabled = value
case .fakeProfileEnabled:
SGSimpleSettings.shared.fakeProfileEnabled = value
}
}, updateSliderValue: { setting, value in
switch (setting) {
case .accountColorsSaturation:
if SGSimpleSettings.shared.accountColorsSaturation != value {
SGSimpleSettings.shared.accountColorsSaturation = value
simplePromise.set(true)
}
case .outgoingPhotoQuality:
if SGSimpleSettings.shared.outgoingPhotoQuality != value {
SGSimpleSettings.shared.outgoingPhotoQuality = value
simplePromise.set(true)
}
case .stickerSize:
if SGSimpleSettings.shared.stickerSize != value {
SGSimpleSettings.shared.stickerSize = value
simplePromise.set(true)
}
}
}, setOneFromManyValue: { setting in
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
let actionSheet = ActionSheetController(presentationData: presentationData)
var items: [ActionSheetItem] = []
switch (setting) {
case .downloadSpeedBoost:
let setAction: (String) -> Void = { value in
SGSimpleSettings.shared.downloadSpeedBoost = value
let enableDownloadX: Bool
switch (value) {
case SGSimpleSettings.DownloadSpeedBoostValues.none.rawValue:
enableDownloadX = false
default:
enableDownloadX = true
}
// Updating controller
simplePromise.set(true)
let _ = updateNetworkSettingsInteractively(postbox: context.account.postbox, network: context.account.network, { settings in
var settings = settings
settings.useExperimentalDownload = enableDownloadX
return settings
}).start(completed: {
Queue.mainQueue().async {
askForRestart?()
}
})
}
for value in SGSimpleSettings.DownloadSpeedBoostValues.allCases {
items.append(ActionSheetButtonItem(title: i18n("Settings.DownloadsBoost.\(value.rawValue)", presentationData.strings.baseLanguageCode), color: .accent, action: { [weak actionSheet] in
actionSheet?.dismissAnimated()
setAction(value.rawValue)
}))
}
case .bottomTabStyle:
let setAction: (String) -> Void = { value in
SGSimpleSettings.shared.bottomTabStyle = value
simplePromise.set(true)
}
for value in SGSimpleSettings.BottomTabStyleValues.allCases {
items.append(ActionSheetButtonItem(title: i18n("Settings.Folders.BottomTabStyle.\(value.rawValue)", presentationData.strings.baseLanguageCode), color: .accent, action: { [weak actionSheet] in
actionSheet?.dismissAnimated()
setAction(value.rawValue)
}))
}
case .allChatsTitleLengthOverride:
let setAction: (String) -> Void = { value in
SGSimpleSettings.shared.allChatsTitleLengthOverride = value
simplePromise.set(true)
}
for value in SGSimpleSettings.AllChatsTitleLengthOverride.allCases {
let title: String
switch (value) {
case SGSimpleSettings.AllChatsTitleLengthOverride.short:
title = "\"\(presentationData.strings.ChatList_Tabs_All)\""
case SGSimpleSettings.AllChatsTitleLengthOverride.long:
title = "\"\(presentationData.strings.ChatList_Tabs_AllChats)\""
default:
title = i18n("Settings.Folders.AllChatsTitle.none", presentationData.strings.baseLanguageCode)
}
items.append(ActionSheetButtonItem(title: title, color: .accent, action: { [weak actionSheet] in
actionSheet?.dismissAnimated()
setAction(value.rawValue)
}))
}
// case .allChatsFolderPositionOverride:
// let setAction: (String) -> Void = { value in
// SGSimpleSettings.shared.allChatsFolderPositionOverride = value
// simplePromise.set(true)
// }
//
// for value in SGSimpleSettings.AllChatsFolderPositionOverride.allCases {
// items.append(ActionSheetButtonItem(title: i18n("Settings.Folders.AllChatsTitle.\(value)", presentationData.strings.baseLanguageCode), color: .accent, action: { [weak actionSheet] in
// actionSheet?.dismissAnimated()
// setAction(value.rawValue)
// }))
// }
case .translationBackend:
let setAction: (String) -> Void = { value in
SGSimpleSettings.shared.translationBackend = value
simplePromise.set(true)
}
for value in SGSimpleSettings.TranslationBackend.allCases {
if value == .system {
if #available(iOS 18.0, *) {
} else {
continue // System translation is not available on iOS 17 and below
}
}
items.append(ActionSheetButtonItem(title: i18n("Settings.Translation.Backend.\(value.rawValue)", presentationData.strings.baseLanguageCode), color: .accent, action: { [weak actionSheet] in
actionSheet?.dismissAnimated()
setAction(value.rawValue)
}))
}
case .transcriptionBackend:
let setAction: (String) -> Void = { value in
SGSimpleSettings.shared.transcriptionBackend = value
simplePromise.set(true)
}
for value in SGSimpleSettings.TranscriptionBackend.allCases {
if #available(iOS 13.0, *) {
} else {
if value == .apple {
continue // Apple recognition is not available on iOS 12
}
}
items.append(ActionSheetButtonItem(title: i18n("Settings.Transcription.Backend.\(value.rawValue)", presentationData.strings.baseLanguageCode), color: .accent, action: { [weak actionSheet] in
actionSheet?.dismissAnimated()
setAction(value.rawValue)
}))
}
case .nyStyle:
let setAction: (String) -> Void = { value in
SGSimpleSettings.shared.nyStyle = value
simplePromise.set(true)
}
for value in SGSimpleSettings.NYStyle.allCases {
items.append(ActionSheetButtonItem(title: i18n("Settings.NY.Style.\(value.rawValue)", presentationData.strings.baseLanguageCode), color: .accent, action: { [weak actionSheet] in
actionSheet?.dismissAnimated()
setAction(value.rawValue)
}))
}
}
actionSheet.setItemGroups([ActionSheetItemGroup(items: items), ActionSheetItemGroup(items: [
ActionSheetButtonItem(title: presentationData.strings.Common_Cancel, color: .accent, font: .bold, action: { [weak actionSheet] in
actionSheet?.dismissAnimated()
})
])])
presentControllerImpl?(actionSheet, ViewControllerPresentationArguments(presentationAnimation: .modalSheet))
}, openDisclosureLink: { link in
switch (link) {
case .languageSettings:
pushControllerImpl?(context.sharedContext.makeLocalizationListController(context: context))
case .contentSettings:
let _ = (getSGSettingsURL(context: context) |> deliverOnMainQueue).start(next: { [weak context] url in
guard let strongContext = context else {
return
}
strongContext.sharedContext.applicationBindings.openUrl(url)
})
}
}, action: { actionType in
#if canImport(SGDeletedMessages)
if let actionString = actionType as? String, actionString == "clearDeletedMessages" {
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
let alertController = textAlertController(
context: context,
title: presentationData.strings.baseLanguageCode == "ru" ? "Очистить все сохраненные удаленные сообщения?" : "Clear All Saved Deleted Messages?",
text: presentationData.strings.baseLanguageCode == "ru" ? "Это действие удалит все сообщения, которые были помечены как удаленные. Это действие нельзя отменить." : "This action will permanently delete all messages that were marked as deleted. This action cannot be undone.",
actions: [
TextAlertAction(type: .destructiveAction, title: presentationData.strings.Common_Delete, action: {
let statusController = OverlayStatusController(theme: presentationData.theme, type: .loading(cancelled: nil))
presentControllerImpl?(statusController, nil)
let _ = (SGDeletedMessages.clearAllDeletedMessages(postbox: context.account.postbox)
|> deliverOnMainQueue).start(completed: {
statusController.dismiss()
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
presentControllerImpl?(OverlayStatusController(theme: presentationData.theme, type: .success), nil)
})
}),
TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_Cancel, action: {})
]
)
presentControllerImpl?(alertController, nil)
}
#endif
}, searchInput: { searchQuery in
updateState { state in
var updatedState = state
updatedState.searchQuery = searchQuery
return updatedState
}
})
let sharedData = context.sharedContext.accountManager.sharedData(keys: [ApplicationSpecificSharedDataKeys.callListSettings, ApplicationSpecificSharedDataKeys.experimentalUISettings])
let preferences = context.account.postbox.preferencesView(keys: [ApplicationSpecificPreferencesKeys.SGUISettings, PreferencesKeys.appConfiguration])
let updatedContentSettingsConfiguration = contentSettingsConfiguration(network: context.account.network)
|> map(Optional.init)
let contentSettingsConfiguration = Promise<ContentSettingsConfiguration?>()
contentSettingsConfiguration.set(.single(nil)
|> then(updatedContentSettingsConfiguration))
let signal = combineLatest(simplePromise.get(), /*sliderPromise.get(),*/ statePromise.get(), context.sharedContext.presentationData, sharedData, preferences, contentSettingsConfiguration.get(),
context.engine.accountData.observeAvailableColorOptions(scope: .replies),
context.engine.accountData.observeAvailableColorOptions(scope: .profile)
)
|> map { _, /*sliderValue,*/ state, presentationData, sharedData, view, contentSettingsConfiguration, availableReplyColors, availableProfileColors -> (ItemListControllerState, (ItemListNodeState, Any)) in
let sgUISettings: SGUISettings = view.values[ApplicationSpecificPreferencesKeys.SGUISettings]?.get(SGUISettings.self) ?? SGUISettings.default
let appConfiguration: AppConfiguration = view.values[PreferencesKeys.appConfiguration]?.get(AppConfiguration.self) ?? AppConfiguration.defaultValue
let callListSettings: CallListSettings = sharedData.entries[ApplicationSpecificSharedDataKeys.callListSettings]?.get(CallListSettings.self) ?? CallListSettings.defaultSettings
let experimentalUISettings: ExperimentalUISettings = sharedData.entries[ApplicationSpecificSharedDataKeys.experimentalUISettings]?.get(ExperimentalUISettings.self) ?? ExperimentalUISettings.defaultSettings
let entries = SGControllerEntries(presentationData: presentationData, callListSettings: callListSettings, experimentalUISettings: experimentalUISettings, SGSettings: sgUISettings, appConfiguration: appConfiguration, nameColors: PeerNameColors.with(availableReplyColors: availableReplyColors, availableProfileColors: availableProfileColors), state: state)
let controllerState = ItemListControllerState(presentationData: ItemListPresentationData(presentationData), title: .text("Swiftgram"), leftNavigationButton: nil, rightNavigationButton: nil, backNavigationButton: ItemListBackButton(title: presentationData.strings.Common_Back))
// TODO(swiftgram): focusOnItemTag support
/* var index = 0
var scrollToItem: ListViewScrollToItem?
if let focusOnItemTag = focusOnItemTag {
for entry in entries {
if entry.tag?.isEqual(to: focusOnItemTag) ?? false {
scrollToItem = ListViewScrollToItem(index: index, position: .top(0.0), animated: false, curve: .Default(duration: 0.0), directionHint: .Up)
}
index += 1
}
} */
let listState = ItemListNodeState(presentationData: ItemListPresentationData(presentationData), entries: entries, style: .blocks, ensureVisibleItemTag: /*focusOnItemTag*/ nil, initialScrollToItem: nil /* scrollToItem*/ )
return (controllerState, (listState, arguments))
}
let controller = ItemListController(context: context, state: signal)
presentControllerImpl = { [weak controller] c, a in
controller?.present(c, in: .window(.root), with: a)
}
pushControllerImpl = { [weak controller] c in
(controller?.navigationController as? NavigationController)?.pushViewController(c)
}
// getRootControllerImpl = { [weak controller] in
// return controller?.view.window?.rootViewController
// }
// getNavigationControllerImpl = { [weak controller] in
// return controller?.navigationController as? NavigationController
// }
askForRestart = { [weak context] in
guard let context = context else {
return
}
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
presentControllerImpl?(
UndoOverlayController(
presentationData: presentationData,
content: .info(title: nil, // i18n("Common.RestartRequired", presentationData.strings.baseLanguageCode),
text: i18n("Common.RestartRequired", presentationData.strings.baseLanguageCode),
timeout: nil,
customUndoText: i18n("Common.RestartNow", presentationData.strings.baseLanguageCode) //presentationData.strings.Common_Yes
),
elevatedLayout: false,
action: { action in if action == .undo { exit(0) }; return true }
),
nil
)
}
return controller
}
```