mirror of
https://github.com/TelegramMessenger/Telegram-iOS.git
synced 2026-07-05 19:28:46 +02:00
328 lines
11 KiB
Swift
328 lines
11 KiB
Swift
import Foundation
|
|
import UIKit
|
|
import Display
|
|
import SwiftSignalKit
|
|
import ComponentFlow
|
|
import TelegramCore
|
|
import AccountContext
|
|
import OverlayStatusController
|
|
import UrlWhitelist
|
|
import TelegramPresentationData
|
|
import AlertComponent
|
|
import AlertCheckComponent
|
|
import AlertWebpagePreviewComponent
|
|
import UrlHandling
|
|
|
|
public func openUserGeneratedUrl(
|
|
context: AccountContext,
|
|
peerId: EnginePeer.Id?,
|
|
url: String,
|
|
webpage: TelegramMediaWebpage? = nil,
|
|
concealed: Bool,
|
|
forceConcealed: Bool = false,
|
|
skipUrlAuth: Bool = false,
|
|
skipConcealedAlert: Bool = false,
|
|
forceDark: Bool = false,
|
|
present: @escaping (ViewController) -> Void,
|
|
openResolved: @escaping (ResolvedUrl) -> Void,
|
|
progress: Promise<Bool>? = nil,
|
|
alertDisplayUpdated: ((ViewController?) -> Void)? = nil,
|
|
concealedAlertOption: OpenUserGeneratedUrlConcealedAlertOption? = nil
|
|
) -> Disposable {
|
|
var concealed = concealed
|
|
|
|
var presentationData = context.sharedContext.currentPresentationData.with { $0 }
|
|
if forceDark {
|
|
presentationData = presentationData.withUpdated(theme: defaultDarkColorPresentationTheme)
|
|
}
|
|
|
|
let openImpl: () -> Disposable = {
|
|
let disposable = MetaDisposable()
|
|
var cancelImpl: (() -> Void)?
|
|
let progressSignal: Signal<Never, NoError>
|
|
|
|
if let progress {
|
|
progressSignal = Signal<Never, NoError> { subscriber in
|
|
progress.set(.single(true))
|
|
return ActionDisposable {
|
|
progress.set(.single(false))
|
|
}
|
|
}
|
|
|> runOn(Queue.mainQueue())
|
|
} else {
|
|
progressSignal = Signal<Never, NoError> { subscriber in
|
|
let controller = OverlayStatusController(style: presentationData.theme.actionSheet.backgroundType == .light ? .light : .dark, type: .loading(cancelled: {
|
|
cancelImpl?()
|
|
}))
|
|
present(controller)
|
|
return ActionDisposable { [weak controller] in
|
|
Queue.mainQueue().async() {
|
|
controller?.dismiss()
|
|
}
|
|
}
|
|
}
|
|
|> runOn(Queue.mainQueue())
|
|
}
|
|
let progressDisposable = MetaDisposable()
|
|
var didStartProgress = false
|
|
|
|
cancelImpl = {
|
|
disposable.dispose()
|
|
}
|
|
|
|
var resolveSignal: Signal<ResolveUrlResult, NoError>
|
|
resolveSignal = context.sharedContext.resolveUrlWithProgress(context: context, peerId: peerId, url: url, skipUrlAuth: skipUrlAuth)
|
|
#if DEBUG
|
|
//resolveSignal = .single(.progress) |> then(resolveSignal |> delay(2.0, queue: .mainQueue()))
|
|
#endif
|
|
|
|
disposable.set((resolveSignal
|
|
|> afterDisposed {
|
|
Queue.mainQueue().async {
|
|
progressDisposable.dispose()
|
|
}
|
|
}
|
|
|> deliverOnMainQueue).start(next: { result in
|
|
switch result {
|
|
case .progress:
|
|
if !didStartProgress {
|
|
didStartProgress = true
|
|
progressDisposable.set(progressSignal.start())
|
|
}
|
|
case let .result(result):
|
|
progressDisposable.dispose()
|
|
openResolved(result)
|
|
}
|
|
}))
|
|
|
|
return ActionDisposable {
|
|
cancelImpl?()
|
|
}
|
|
}
|
|
|
|
let (parsedString, parsedConcealed) = parseUrl(url: url, wasConcealed: concealed)
|
|
concealed = parsedConcealed
|
|
|
|
if forceConcealed {
|
|
concealed = true
|
|
} else if let parsedUrl = parseInternalUrl(sharedContext: context.sharedContext, context: context, query: url) {
|
|
if case .proxy = parsedUrl {
|
|
concealed = true
|
|
}
|
|
}
|
|
|
|
if concealed && !skipConcealedAlert {
|
|
var rawDisplayUrl: String = parsedString
|
|
let maxLength = 180
|
|
if rawDisplayUrl.count > maxLength {
|
|
rawDisplayUrl = String(rawDisplayUrl[..<rawDisplayUrl.index(rawDisplayUrl.startIndex, offsetBy: maxLength - 2)]) + "..."
|
|
}
|
|
|
|
var displayUrl = rawDisplayUrl
|
|
displayUrl = displayUrl.replacingOccurrences(of: "\u{202e}", with: "")
|
|
displayUrl = (try? punycodedFullURLString(displayUrl)) ?? displayUrl
|
|
|
|
let disposable = MetaDisposable()
|
|
let checkState = concealedAlertOption.map { _ in AlertCheckComponent.ExternalState() }
|
|
|
|
var content: [AnyComponentWithIdentity<AlertComponentEnvironment>] = []
|
|
content.append(AnyComponentWithIdentity(id: "title", component: AnyComponent(AlertTitleComponent(title: presentationData.strings.OpenLinkConfirmation_Title))))
|
|
content.append(AnyComponentWithIdentity(id: "text", component: AnyComponent(AlertTextComponent(
|
|
content: .plain(displayUrl),
|
|
alignment: .center,
|
|
color: .primary,
|
|
style: .background(.default),
|
|
insets: UIEdgeInsets(top: 9.0, left: 4.0, bottom: 0.0, right: 4.0)
|
|
))))
|
|
if let webpage, case .Loaded = webpage.content {
|
|
content.append(AnyComponentWithIdentity(id: "webpagePreview", component: AnyComponent(AlertWebpagePreviewComponent(
|
|
context: context,
|
|
presentationData: presentationData,
|
|
webpage: webpage
|
|
))))
|
|
}
|
|
if let concealedAlertOption, let checkState {
|
|
content.append(AnyComponentWithIdentity(id: "check", component: AnyComponent(AlertCheckComponent(
|
|
title: concealedAlertOption.title,
|
|
initialValue: false,
|
|
externalState: checkState
|
|
))))
|
|
}
|
|
|
|
var updatedPresentationDataSignal = context.sharedContext.presentationData
|
|
if forceDark {
|
|
updatedPresentationDataSignal = .single(presentationData)
|
|
}
|
|
let controller = AlertScreen(
|
|
content: content,
|
|
actions: [
|
|
.init(title: presentationData.strings.Common_Cancel),
|
|
.init(title: presentationData.strings.OpenLinkConfirmation_Open, type: .default, action: {
|
|
if checkState?.value == true {
|
|
concealedAlertOption?.action()
|
|
}
|
|
disposable.set(openImpl())
|
|
}),
|
|
],
|
|
updatedPresentationData: (presentationData, updatedPresentationDataSignal)
|
|
)
|
|
controller.dismissed = { _ in
|
|
alertDisplayUpdated?(nil)
|
|
}
|
|
present(controller)
|
|
alertDisplayUpdated?(controller)
|
|
return disposable
|
|
} else {
|
|
return openImpl()
|
|
}
|
|
}
|
|
|
|
private enum PunycodeError: Error {
|
|
case emptyInput
|
|
case overflow
|
|
}
|
|
|
|
private struct Punycode {
|
|
private static let base = 36
|
|
private static let tmin = 1
|
|
private static let tmax = 26
|
|
private static let skew = 38
|
|
private static let damp = 700
|
|
private static let initialBias = 72
|
|
private static let initialN: UInt32 = 128
|
|
private static let delimiter: Character = "-"
|
|
|
|
/// Encodes a single DNS label to punycode WITHOUT "xn--" prefix.
|
|
static func encodeLabel(_ input: String) throws -> String {
|
|
guard !input.isEmpty else { throw PunycodeError.emptyInput }
|
|
|
|
let scalars: [UInt32] = input.unicodeScalars.map { $0.value }
|
|
var output = ""
|
|
|
|
var b = 0
|
|
for cp in scalars where cp < 0x80 {
|
|
output.unicodeScalars.append(UnicodeScalar(cp)!)
|
|
b += 1
|
|
}
|
|
|
|
var h = b
|
|
if b > 0 && h < scalars.count { output.append(delimiter) }
|
|
|
|
var n = initialN
|
|
var delta: UInt32 = 0
|
|
var bias = initialBias
|
|
|
|
func checkedAdd(_ a: UInt32, _ b: UInt32) throws -> UInt32 {
|
|
let (res, overflow) = a.addingReportingOverflow(b)
|
|
if overflow { throw PunycodeError.overflow }
|
|
return res
|
|
}
|
|
func checkedMul(_ a: UInt32, _ b: UInt32) throws -> UInt32 {
|
|
let (res, overflow) = a.multipliedReportingOverflow(by: b)
|
|
if overflow { throw PunycodeError.overflow }
|
|
return res
|
|
}
|
|
|
|
while h < scalars.count {
|
|
var m: UInt32 = UInt32.max
|
|
for cp in scalars where cp >= n {
|
|
if cp < m { m = cp }
|
|
}
|
|
|
|
let hm1 = UInt32(h + 1)
|
|
delta = try checkedAdd(delta, try checkedMul(m - n, hm1))
|
|
n = m
|
|
|
|
for cp in scalars {
|
|
if cp < n {
|
|
delta = try checkedAdd(delta, 1)
|
|
} else if cp == n {
|
|
var q = delta
|
|
var k = base
|
|
|
|
while true {
|
|
let t: UInt32
|
|
if k <= bias { t = UInt32(tmin) }
|
|
else if k >= bias + tmax { t = UInt32(tmax) }
|
|
else { t = UInt32(k - bias) }
|
|
|
|
if q < t { break }
|
|
|
|
let digit = Int(t + (q - t) % UInt32(base - Int(t)))
|
|
output.append(encodeDigit(digit))
|
|
q = (q - t) / UInt32(base - Int(t))
|
|
k += base
|
|
}
|
|
|
|
output.append(encodeDigit(Int(q)))
|
|
bias = adapt(delta: delta, numPoints: h + 1, firstTime: h == b)
|
|
delta = 0
|
|
h += 1
|
|
}
|
|
}
|
|
|
|
delta = try checkedAdd(delta, 1)
|
|
n = try checkedAdd(n, 1)
|
|
}
|
|
|
|
return output
|
|
}
|
|
|
|
/// Encodes a domain name label-by-label; prefixes "xn--" only when needed.
|
|
static func encodeDomain(_ domain: String) throws -> String {
|
|
let normalized = domain.precomposedStringWithCanonicalMapping
|
|
|
|
return try normalized
|
|
.split(separator: ".", omittingEmptySubsequences: false)
|
|
.map { piece -> String in
|
|
let label = String(piece)
|
|
if label.isEmpty { return label } // keep empty labels as-is (rare)
|
|
if label.unicodeScalars.allSatisfy({ $0.isASCII }) { return label }
|
|
return "xn--" + (try encodeLabel(label))
|
|
}
|
|
.joined(separator: ".")
|
|
}
|
|
|
|
private static func encodeDigit(_ d: Int) -> Character {
|
|
precondition((0..<36).contains(d))
|
|
if d < 26 { return Character(UnicodeScalar(UInt8(97 + d))) } // a-z
|
|
else { return Character(UnicodeScalar(UInt8(48 + (d - 26)))) } // 0-9
|
|
}
|
|
|
|
private static func adapt(delta: UInt32, numPoints: Int, firstTime: Bool) -> Int {
|
|
var d = Int(delta)
|
|
d = firstTime ? d / damp : d / 2
|
|
d += d / numPoints
|
|
|
|
var k = 0
|
|
while d > ((base - tmin) * tmax) / 2 {
|
|
d /= (base - tmin)
|
|
k += base
|
|
}
|
|
|
|
return k + (base - tmin + 1) * d / (d + skew)
|
|
}
|
|
}
|
|
|
|
private enum URLPunycodeError: Error {
|
|
case invalidURL
|
|
}
|
|
|
|
/// Converts a full URL string to an ASCII-safe form:
|
|
/// - punycodes the host (IDN) using RFC 3492 punycode
|
|
/// - leaves scheme/port/user/pass intact
|
|
/// - lets URLComponents percent-encode path/query/fragment as needed
|
|
private func punycodedFullURLString(_ input: String) throws -> String {
|
|
guard var comps = URLComponents(string: input) else {
|
|
throw URLPunycodeError.invalidURL
|
|
}
|
|
|
|
// If there is a host, encode it. If it's a relative URL, host may be nil.
|
|
if let host = comps.host, !host.isEmpty {
|
|
comps.host = try Punycode.encodeDomain(host)
|
|
}
|
|
|
|
// URLComponents.string can be nil for some malformed component combinations
|
|
if let out = comps.string { return out }
|
|
throw URLPunycodeError.invalidURL
|
|
}
|