Experiment

This commit is contained in:
Isaac 2026-02-04 03:22:37 +08:00
parent facd51e0f5
commit 8c54db62f3
23 changed files with 3901 additions and 125 deletions

View file

@ -1978,13 +1978,18 @@ private final class NotificationServiceHandler {
if let media = message.media.first {
parsedMedia = media
}
if enableInlineEmoji, let textEntitiesAttribute = message.textEntitiesAttribute {
content.body = message.text
if enableInlineEmoji, let textEntitiesAttribute = message.textEntitiesAttribute, let author = message.author {
let authorTitle = author.debugDisplayTitle
let messagePrefix = "\(authorTitle): "
let messagePrefixLength = (messagePrefix as NSString).length
for entity in textEntitiesAttribute.entities {
if case let .CustomEmoji(_, fileId) = entity.type {
content.customEmoji.append(NotificationContent.CustomEmoji(range: entity.range, fileId: fileId))
content.customEmoji.append(NotificationContent.CustomEmoji(range: (entity.range.lowerBound + messagePrefixLength) ..< (entity.range.upperBound + messagePrefixLength), fileId: fileId))
}
}
if !content.customEmoji.isEmpty {
content.body = messagePrefix + message.text
}
}
}

View file

@ -109,7 +109,7 @@ private enum DebugControllerEntry: ItemListNodeEntry {
case playerV2(Bool)
case devRequests(Bool)
case enableUpdates(Bool)
case fakeAds(Bool)
case pwa(Bool)
case enableLocalTranslation(Bool)
case preferredVideoCodec(Int, String, String?, Bool)
case disableVideoAspectScaling(Bool)
@ -135,7 +135,7 @@ private enum DebugControllerEntry: ItemListNodeEntry {
return DebugControllerSection.web.rawValue
case .keepChatNavigationStack, .skipReadHistory, .alwaysDisplayTyping, .debugRatingLayout, .crashOnSlowQueries, .crashOnMemoryPressure:
return DebugControllerSection.experiments.rawValue
case .clearTips, .resetNotifications, .crash, .fillLocalSavedMessageCache, .resetDatabase, .resetDatabaseAndCache, .resetHoles, .resetTagHoles, .reindexUnread, .resetCacheIndex, .reindexCache, .resetBiometricsData, .optimizeDatabase, .photoPreview, .knockoutWallpaper, .compressedEmojiCache, .storiesJpegExperiment, .checkSerializedData, .enableQuickReactionSwitch, .experimentalCompatibility, .enableDebugDataDisplay, .fakeGlass, .browserExperiment, .allForumsHaveTabs, .enableReactionOverrides, .restorePurchases, .disableReloginTokens, .liveStreamV2, .experimentalCallMute, .playerV2, .devRequests, .enableUpdates, .fakeAds, .enableLocalTranslation:
case .clearTips, .resetNotifications, .crash, .fillLocalSavedMessageCache, .resetDatabase, .resetDatabaseAndCache, .resetHoles, .resetTagHoles, .reindexUnread, .resetCacheIndex, .reindexCache, .resetBiometricsData, .optimizeDatabase, .photoPreview, .knockoutWallpaper, .compressedEmojiCache, .storiesJpegExperiment, .checkSerializedData, .enableQuickReactionSwitch, .experimentalCompatibility, .enableDebugDataDisplay, .fakeGlass, .browserExperiment, .allForumsHaveTabs, .enableReactionOverrides, .restorePurchases, .disableReloginTokens, .liveStreamV2, .experimentalCallMute, .playerV2, .devRequests, .enableUpdates, .pwa, .enableLocalTranslation:
return DebugControllerSection.experiments.rawValue
case .logTranslationRecognition, .resetTranslationStates:
return DebugControllerSection.translation.rawValue
@ -258,7 +258,7 @@ private enum DebugControllerEntry: ItemListNodeEntry {
return 54
case .devRequests:
return 55
case .fakeAds:
case .pwa:
return 56
case .enableLocalTranslation:
return 57
@ -1392,12 +1392,12 @@ private enum DebugControllerEntry: ItemListNodeEntry {
})
}).start()
})
case let .fakeAds(value):
return ItemListSwitchItem(presentationData: presentationData, systemStyle: .glass, title: "Fake Ads", value: value, sectionId: self.section, style: .blocks, updated: { value in
case let .pwa(value):
return ItemListSwitchItem(presentationData: presentationData, systemStyle: .glass, title: "Test1", value: value, sectionId: self.section, style: .blocks, updated: { value in
let _ = arguments.sharedContext.accountManager.transaction ({ transaction in
transaction.updateSharedData(ApplicationSpecificSharedDataKeys.experimentalUISettings, { settings in
var settings = settings?.get(ExperimentalUISettings.self) ?? ExperimentalUISettings.defaultSettings
settings.fakeAds = value
settings.enablePWA = value
return PreferencesEntry(settings)
})
}).start()
@ -1488,7 +1488,7 @@ private enum DebugControllerEntry: ItemListNodeEntry {
}
}
private func debugControllerEntries(sharedContext: SharedAccountContext, presentationData: PresentationData, loggingSettings: LoggingSettings, mediaInputSettings: MediaInputSettings, experimentalSettings: ExperimentalUISettings, networkSettings: NetworkSettings?, hasLegacyAppData: Bool, useBetaFeatures: Bool) -> [DebugControllerEntry] {
private func debugControllerEntries(context: AccountContext?, sharedContext: SharedAccountContext, presentationData: PresentationData, loggingSettings: LoggingSettings, mediaInputSettings: MediaInputSettings, experimentalSettings: ExperimentalUISettings, networkSettings: NetworkSettings?, hasLegacyAppData: Bool, useBetaFeatures: Bool) -> [DebugControllerEntry] {
var entries: [DebugControllerEntry] = []
let isMainApp = sharedContext.applicationBindings.isMainApp
@ -1572,7 +1572,18 @@ private func debugControllerEntries(sharedContext: SharedAccountContext, present
entries.append(.playerV2(experimentalSettings.playerV2))
entries.append(.devRequests(experimentalSettings.devRequests))
entries.append(.fakeAds(experimentalSettings.fakeAds))
if let data = context?.currentAppConfiguration.with({ $0 }).data {
var displayPwa = false
if let _ = data["ios_display_pwa"] {
displayPwa = true
} else if let isDev = data["dev"] as? Double, isDev == 1.0 {
displayPwa = true
}
if displayPwa {
entries.append(.pwa(experimentalSettings.enablePWA))
}
}
entries.append(.enableLocalTranslation(experimentalSettings.enableLocalTranslation))
entries.append(.enableUpdates(experimentalSettings.enableUpdates))
}
@ -1658,7 +1669,7 @@ public func debugController(sharedContext: SharedAccountContext, context: Accoun
}
let controllerState = ItemListControllerState(presentationData: ItemListPresentationData(presentationData), title: .text("Debug"), leftNavigationButton: leftNavigationButton, rightNavigationButton: nil, backNavigationButton: ItemListBackButton(title: presentationData.strings.Common_Back))
let listState = ItemListNodeState(presentationData: ItemListPresentationData(presentationData), entries: debugControllerEntries(sharedContext: sharedContext, presentationData: presentationData, loggingSettings: loggingSettings, mediaInputSettings: mediaInputSettings, experimentalSettings: experimentalSettings, networkSettings: networkSettings, hasLegacyAppData: hasLegacyAppData, useBetaFeatures: useBetaFeatures), style: .blocks)
let listState = ItemListNodeState(presentationData: ItemListPresentationData(presentationData), entries: debugControllerEntries(context: context, sharedContext: sharedContext, presentationData: presentationData, loggingSettings: loggingSettings, mediaInputSettings: mediaInputSettings, experimentalSettings: experimentalSettings, networkSettings: networkSettings, hasLegacyAppData: hasLegacyAppData, useBetaFeatures: useBetaFeatures), style: .blocks)
return (controllerState, (listState, arguments))
}

View file

@ -729,7 +729,7 @@ final class MediaReferenceRevalidationContext {
func attachBot(accountPeerId: PeerId, postbox: Postbox, network: Network, background: Bool, peer: PeerReference) -> Signal<AttachMenuBot, RevalidateMediaReferenceError> {
return self.genericItem(key: .attachBot(peer: peer), background: background, request: { next, error in
return (_internal_getAttachMenuBot(accountPeerId: accountPeerId, postbox: postbox, network: network, botId: peer.id, cached: false)
return (_internal_getAttachMenuBot(accountPeerId: accountPeerId, postbox: postbox, network: network, botId: peer.id, cached: false, allowManuallyCached: false)
|> mapError { _ -> RevalidateMediaReferenceError in
return .generic
}).start(next: { value in

View file

@ -149,6 +149,8 @@ public struct Namespaces {
public static let cachedChatThemes: Int8 = 50
public static let cachedLiveStorySendAsPeers: Int8 = 51
public static let cachedGiftUpgradesAttributes: Int8 = 52
public static let manuallyCachedAttachMenuBots: Int8 = 53
public static let cachedBotWebViews: Int8 = 54
}
public struct UnorderedItemList {

View file

@ -283,6 +283,51 @@ private func removeCachedAttachMenuBot(postbox: Postbox, botId: PeerId) -> Signa
}
}
enum CachedAttachMenuBotResult {
case empty
case bot(AttachMenuBots.Bot)
}
private func manuallyCachedAttachMenuBot(transaction: Transaction, peerId: PeerId) -> CachedAttachMenuBotResult? {
let key = ValueBoxKey(length: 8)
key.setInt64(0, value: peerId.toInt64())
if let entry = transaction.retrieveItemCacheEntry(id: ItemCacheEntryId(collectionId: Namespaces.CachedItemCollection.manuallyCachedAttachMenuBots, key: key)) {
let cached = entry.get(AttachMenuBots.Bot.self)
if let cached {
return .bot(cached)
} else if let _ = entry.get(EmptyAttachMenuBotMarker.self) {
return .empty
} else {
return nil
}
} else {
return nil
}
}
struct EmptyAttachMenuBotMarker: Codable {
var trueValue: Bool
}
private func setManuallyCachedAttachMenuBot(transaction: Transaction, peerId: PeerId, bot: AttachMenuBots.Bot?) {
let key = ValueBoxKey(length: 8)
key.setInt64(0, value: peerId.toInt64())
let entryId = ItemCacheEntryId(collectionId: Namespaces.CachedItemCollection.manuallyCachedAttachMenuBots, key: key)
if let bot {
if let entry = CodableEntry(bot) {
transaction.putItemCacheEntry(id: entryId, entry: entry)
} else {
transaction.removeItemCacheEntry(id: entryId)
}
} else {
if let entry = CodableEntry(EmptyAttachMenuBotMarker(trueValue: true)) {
transaction.putItemCacheEntry(id: entryId, entry: entry)
}
}
}
func managedSynchronizeAttachMenuBots(accountPeerId: PeerId, postbox: Postbox, network: Network, force: Bool = false) -> Signal<Void, NoError> {
let poll = Signal<Void, NoError> { subscriber in
let signal: Signal<Void, NoError> = cachedAttachMenuBots(postbox: postbox)
@ -509,19 +554,33 @@ public enum GetAttachMenuBotError {
case generic
}
func _internal_getAttachMenuBot(accountPeerId: PeerId, postbox: Postbox, network: Network, botId: PeerId, cached: Bool) -> Signal<AttachMenuBot, GetAttachMenuBotError> {
func _internal_getAttachMenuBot(accountPeerId: PeerId, postbox: Postbox, network: Network, botId: PeerId, cached: Bool, allowManuallyCached: Bool) -> Signal<AttachMenuBot, GetAttachMenuBotError> {
return postbox.transaction { transaction -> Signal<AttachMenuBot, GetAttachMenuBotError> in
if cached, let cachedBots = cachedAttachMenuBots(transaction: transaction)?.bots {
if let bot = cachedBots.first(where: { $0.peerId == botId }), let peer = transaction.getPeer(bot.peerId) {
return .single(AttachMenuBot(peer: EnginePeer(peer), shortName: bot.name, icons: bot.icons, peerTypes: bot.peerTypes, flags: bot.flags))
}
}
if allowManuallyCached, let bot = manuallyCachedAttachMenuBot(transaction: transaction, peerId: botId) {
switch bot {
case let .bot(bot):
if let peer = transaction.getPeer(bot.peerId) {
return .single(AttachMenuBot(peer: EnginePeer(peer), shortName: bot.name, icons: bot.icons, peerTypes: bot.peerTypes, flags: bot.flags))
}
case .empty:
return .fail(.generic)
}
}
guard let peer = transaction.getPeer(botId), let inputUser = apiInputUser(peer) else {
return .complete()
}
return network.request(Api.functions.messages.getAttachMenuBot(bot: inputUser))
|> mapError { _ -> GetAttachMenuBotError in
let _ = postbox.transaction({ transaction -> Void in
setManuallyCachedAttachMenuBot(transaction: transaction, peerId: peer.id, bot: nil)
}).startStandalone()
return .generic
}
|> mapToSignal { result -> Signal<AttachMenuBot, GetAttachMenuBotError> in
@ -586,6 +645,15 @@ func _internal_getAttachMenuBot(accountPeerId: PeerId, postbox: Postbox, network
if (apiFlags & (1 << 5)) != 0 {
flags.insert(.showInSettingsDisclaimer)
}
setManuallyCachedAttachMenuBot(transaction: transaction, peerId: peer.id, bot: AttachMenuBots.Bot(
peerId: peer.id,
name: name,
icons: icons,
peerTypes: peerTypes,
flags: flags
))
return .single(AttachMenuBot(peer: EnginePeer(peer), shortName: name, icons: icons, peerTypes: peerTypes, flags: flags))
}
}

View file

@ -203,9 +203,24 @@ private func keepWebViewSignal(network: Network, stateManager: AccountStateManag
return signal
}
func _internal_requestWebView(postbox: Postbox, network: Network, stateManager: AccountStateManager, peerId: PeerId, botId: PeerId, url: String?, payload: String?, themeParams: [String: Any]?, fromMenu: Bool, replyToMessageId: MessageId?, threadId: Int64?) -> Signal<RequestWebViewResult, RequestWebViewError> {
func _internal_requestWebView(
postbox: Postbox,
network: Network,
stateManager: AccountStateManager,
peerId: PeerId,
botId: PeerId,
url: String?,
payload: String?,
themeParams: [String: Any]?,
fromMenu: Bool,
replyToMessageId: MessageId?,
threadId: Int64?,
enableCached: Bool
) -> Signal<RequestWebViewResult, RequestWebViewError> {
var serializedThemeParams: Api.DataJSON?
var themeParamsJson: String?
if let themeParams = themeParams, let data = try? JSONSerialization.data(withJSONObject: themeParams, options: []), let dataString = String(data: data, encoding: .utf8) {
themeParamsJson = dataString
serializedThemeParams = .dataJSON(.init(data: dataString))
}
@ -213,6 +228,28 @@ func _internal_requestWebView(postbox: Postbox, network: Network, stateManager:
guard let peer = transaction.getPeer(peerId), let inputPeer = apiInputPeer(peer), let bot = transaction.getPeer(botId), let inputBot = apiInputUser(bot) else {
return .fail(.generic)
}
let _ = themeParamsJson
let cacheKey = CachedBotWebView.Key(
peerId: peerId,
botId: botId,
url: url,
payload: payload,
themeParamsJson: nil,
fromMenu: fromMenu,
replyToMessageId: replyToMessageId,
threadId: threadId
)
if enableCached {
if let value = cachedBotWebView(transaction: transaction, key: cacheKey) {
return .single(RequestWebViewResult(
flags: value.flags,
queryId: value.queryId,
url: value.url,
keepAliveSignal: nil
))
}
}
var flags: Int32 = 0
if let _ = url {
@ -276,6 +313,16 @@ func _internal_requestWebView(postbox: Postbox, network: Network, stateManager:
} else {
keepAlive = nil
}
if enableCached {
let _ = (postbox.transaction { transaction -> Void in
setCachedBotWebView(transaction: transaction, key: cacheKey, value: CachedBotWebView(
flags: resultFlags,
queryId: queryId,
url: url
))
}).startStandalone()
}
return .single(RequestWebViewResult(flags: resultFlags, queryId: queryId, url: url, keepAliveSignal: keepAlive))
}
@ -285,6 +332,99 @@ func _internal_requestWebView(postbox: Postbox, network: Network, stateManager:
|> switchToLatest
}
final class CachedBotWebView: Codable {
struct Key {
let peerId: PeerId
let botId: PeerId
let url: String?
let payload: String?
let themeParamsJson: String?
let fromMenu: Bool
let replyToMessageId: MessageId?
let threadId: Int64?
func toData() -> ValueBoxKey {
var string = ""
string.append("_")
string.append("\(self.peerId.toInt64())")
string.append("_")
string.append("\(self.botId.toInt64())")
string.append("_")
string.append("\(self.url ?? "-")")
string.append("_")
string.append("\(self.payload ?? "-")")
string.append("_")
string.append("\(self.themeParamsJson ?? "-")")
string.append("_")
string.append("\(self.fromMenu)")
string.append("_")
string.append("\(String(describing: self.replyToMessageId))")
string.append("_")
string.append("\(self.threadId ?? -1)")
let data = sha256Digest(string.data(using: .utf8)!)
let key = ValueBoxKey(length: data.count)
key.setData(0, value: data)
return key
}
}
private enum CodingKeys: String, CodingKey {
case flags
case queryId
case url
}
let flags: RequestWebViewResult.Flags
let queryId: Int64?
let url: String
init(flags: RequestWebViewResult.Flags, queryId: Int64?, url: String) {
self.flags = flags
self.queryId = queryId
self.url = url
}
init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.flags = RequestWebViewResult.Flags(rawValue: try container.decode(Int32.self, forKey: .flags))
self.queryId = try container.decodeIfPresent(Int64.self, forKey: .queryId)
self.url = try container.decode(String.self, forKey: .url)
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(self.flags.rawValue, forKey: .flags)
try container.encodeIfPresent(self.queryId, forKey: .queryId)
try container.encodeIfPresent(self.url, forKey: .url)
}
}
private func cachedBotWebView(transaction: Transaction, key: CachedBotWebView.Key) -> CachedBotWebView? {
let key = key.toData()
if let entry = transaction.retrieveItemCacheEntry(id: ItemCacheEntryId(collectionId: Namespaces.CachedItemCollection.cachedBotWebViews, key: key)) {
return entry.get(CachedBotWebView.self)
} else {
return nil
}
}
private func setCachedBotWebView(transaction: Transaction, key: CachedBotWebView.Key, value: CachedBotWebView?) {
let key = key.toData()
let entryId = ItemCacheEntryId(collectionId: Namespaces.CachedItemCollection.cachedBotWebViews, key: key)
if let value {
if let entry = CodableEntry(value) {
transaction.putItemCacheEntry(id: entryId, entry: entry)
} else {
transaction.removeItemCacheEntry(id: entryId)
}
} else {
transaction.removeItemCacheEntry(id: entryId)
}
}
public enum SendWebViewDataError {
case generic
}

View file

@ -671,8 +671,8 @@ public extension TelegramEngine {
return _internal_rateAudioTranscription(postbox: self.account.postbox, network: self.account.network, messageId: messageId, id: id, isGood: isGood)
}
public func requestWebView(peerId: PeerId, botId: PeerId, url: String?, payload: String?, themeParams: [String: Any]?, fromMenu: Bool, replyToMessageId: MessageId?, threadId: Int64?) -> Signal<RequestWebViewResult, RequestWebViewError> {
return _internal_requestWebView(postbox: self.account.postbox, network: self.account.network, stateManager: self.account.stateManager, peerId: peerId, botId: botId, url: url, payload: payload, themeParams: themeParams, fromMenu: fromMenu, replyToMessageId: replyToMessageId, threadId: threadId)
public func requestWebView(peerId: PeerId, botId: PeerId, url: String?, payload: String?, themeParams: [String: Any]?, fromMenu: Bool, replyToMessageId: MessageId?, threadId: Int64?, enableCached: Bool = false) -> Signal<RequestWebViewResult, RequestWebViewError> {
return _internal_requestWebView(postbox: self.account.postbox, network: self.account.network, stateManager: self.account.stateManager, peerId: peerId, botId: botId, url: url, payload: payload, themeParams: themeParams, fromMenu: fromMenu, replyToMessageId: replyToMessageId, threadId: threadId, enableCached: enableCached)
}
public func requestSimpleWebView(botId: PeerId, url: String?, source: RequestSimpleWebViewSource, themeParams: [String: Any]?) -> Signal<RequestWebViewResult, RequestWebViewError> {
@ -719,8 +719,8 @@ public extension TelegramEngine {
return _internal_acceptAttachMenuBotDisclaimer(postbox: self.account.postbox, botId: botId)
}
public func getAttachMenuBot(botId: PeerId, cached: Bool = false) -> Signal<AttachMenuBot, GetAttachMenuBotError> {
return _internal_getAttachMenuBot(accountPeerId: self.account.peerId, postbox: self.account.postbox, network: self.account.network, botId: botId, cached: cached)
public func getAttachMenuBot(botId: PeerId, cached: Bool = false, allowManuallyCached: Bool = false) -> Signal<AttachMenuBot, GetAttachMenuBotError> {
return _internal_getAttachMenuBot(accountPeerId: self.account.peerId, postbox: self.account.postbox, network: self.account.network, botId: botId, cached: cached, allowManuallyCached: allowManuallyCached)
}
public func attachMenuBots() -> Signal<[AttachMenuBot], NoError> {

View file

@ -549,18 +549,18 @@ final class InnerTextSelectionTipContainerNode: ASDisplayNode {
shimmeringForegroundColor = presentationData.theme.contextMenu.primaryColor.withMultipliedAlpha(0.07)
}
let textRightInset: CGFloat
let textRightInset: CGFloat = 8.0
var textLeftInset: CGFloat = horizontalInset
if let _ = self.iconNode.image {
textRightInset = iconSize.width - 2.0
} else {
textRightInset = 0.0
textLeftInset = 60.0
}
let makeTextLayout = TextNodeWithEntities.asyncLayout(self.textNode)
let (textLayout, textApply) = makeTextLayout(TextNodeLayoutArguments(attributedString: attributedText, backgroundColor: nil, minimumNumberOfLines: 0, maximumNumberOfLines: 0, truncationType: .end, constrainedSize: CGSize(width: width - horizontalInset * 2.0 - textRightInset, height: .greatestFiniteMagnitude), alignment: .left, lineSpacing: 0.12, cutout: nil, insets: UIEdgeInsets(), lineColor: nil, textShadowColor: nil, textStroke: nil))
let (textLayout, textApply) = makeTextLayout(TextNodeLayoutArguments(attributedString: attributedText, backgroundColor: nil, minimumNumberOfLines: 0, maximumNumberOfLines: 0, truncationType: .end, constrainedSize: CGSize(width: width - textLeftInset - textRightInset, height: .greatestFiniteMagnitude), alignment: .left, lineSpacing: 0.12, cutout: nil, insets: UIEdgeInsets(), lineColor: nil, textShadowColor: nil, textStroke: nil))
let _ = textApply(self.arguments?.withUpdatedPlaceholderColor(shimmeringForegroundColor))
let textFrame = CGRect(origin: CGPoint(x: horizontalInset, y: topInset), size: textLayout.size)
let textFrame = CGRect(origin: CGPoint(x: textLeftInset, y: topInset), size: textLayout.size)
transition.updateFrame(node: self.textNode.textNode, frame: textFrame)
if textFrame.size.height.isZero {
self.textNode.textNode.alpha = 0.0

View file

@ -115,12 +115,17 @@ func openWebAppImpl(
if case let .inline(bot) = source {
botPeer = bot
}
var allowManuallyCached = false
if context.sharedContext.immediateExperimentalUISettings.enablePWA {
allowManuallyCached = true
}
let _ = combineLatest(queue: Queue.mainQueue(),
context.engine.data.get(TelegramEngine.EngineData.Item.Peer.BotAppSettings(id: botPeer.id)),
ApplicationSpecificNotice.getBotGameNotice(accountManager: context.sharedContext.accountManager, peerId: botPeer.id),
context.engine.messages.attachMenuBots(),
context.engine.messages.getAttachMenuBot(botId: botPeer.id, cached: true)
context.engine.messages.getAttachMenuBot(botId: botPeer.id, cached: true, allowManuallyCached: allowManuallyCached)
|> map(Optional.init)
|> `catch` { _ -> Signal<AttachMenuBot?, NoError> in
return .single(nil)

View file

@ -69,6 +69,7 @@ public struct ExperimentalUISettings: Codable, Equatable {
public var allForumsHaveTabs: Bool
public var debugRatingLayout: Bool
public var enableUpdates: Bool
public var enablePWA: Bool
public static var defaultSettings: ExperimentalUISettings {
return ExperimentalUISettings(
@ -115,7 +116,8 @@ public struct ExperimentalUISettings: Codable, Equatable {
checkSerializedData: false,
allForumsHaveTabs: false,
debugRatingLayout: false,
enableUpdates: false
enableUpdates: false,
enablePWA: false
)
}
@ -163,7 +165,8 @@ public struct ExperimentalUISettings: Codable, Equatable {
checkSerializedData: Bool,
allForumsHaveTabs: Bool,
debugRatingLayout: Bool,
enableUpdates: Bool
enableUpdates: Bool,
enablePWA: Bool
) {
self.keepChatNavigationStack = keepChatNavigationStack
self.skipReadHistory = skipReadHistory
@ -209,6 +212,7 @@ public struct ExperimentalUISettings: Codable, Equatable {
self.allForumsHaveTabs = allForumsHaveTabs
self.debugRatingLayout = debugRatingLayout
self.enableUpdates = enableUpdates
self.enablePWA = enablePWA
}
public init(from decoder: Decoder) throws {
@ -258,6 +262,7 @@ public struct ExperimentalUISettings: Codable, Equatable {
self.allForumsHaveTabs = try container.decodeIfPresent(Bool.self, forKey: "allForumsHaveTabs") ?? false
self.debugRatingLayout = try container.decodeIfPresent(Bool.self, forKey: "debugRatingLayout") ?? false
self.enableUpdates = try container.decodeIfPresent(Bool.self, forKey: "enableUpdates") ?? false
self.enablePWA = try container.decodeIfPresent(Bool.self, forKey: "enablePWA") ?? false
}
public func encode(to encoder: Encoder) throws {
@ -307,6 +312,7 @@ public struct ExperimentalUISettings: Codable, Equatable {
try container.encodeIfPresent(self.allForumsHaveTabs, forKey: "allForumsHaveTabs")
try container.encodeIfPresent(self.debugRatingLayout, forKey: "debugRatingLayout")
try container.encodeIfPresent(self.enableUpdates, forKey: "enableUpdates")
try container.encodeIfPresent(self.enablePWA, forKey: "enablePWA")
}
}

View file

@ -61,6 +61,7 @@ swift_library(
"//submodules/TelegramUI/Components/AvatarComponent",
"//submodules/TelegramUI/Components/AlertComponent/AlertCheckComponent",
"//submodules/TelegramUI/Components/AlertComponent/AlertTransferHeaderComponent",
"//submodules/WebWorkerShim",
],
visibility = [
"//visibility:public",

View file

@ -140,53 +140,6 @@ public func generateWebAppThemeParams(_ theme: PresentationTheme) -> [String: An
]
}
#if DEBUG
private let registeredProtocols: Void = {
class AppURLProtocol: URLProtocol {
var urlTask: URLSessionDataTask?
override class func canInit(with request: URLRequest) -> Bool {
if request.url?.scheme == "https" {
return false
}
return false
}
override class func canonicalRequest(for request: URLRequest) -> URLRequest {
return request
}
override func startLoading() {
super.startLoading()
/*if self.urlTask != nil {
return
}
self.urlTask = URLSession.shared.dataTask(with: self.request, completionHandler: { [weak self] _, response, error in
guard let self else {
return
}
if let error {
self.client?.urlProtocol(self, didFailWithError: error)
} else {
if let response = response as? HTTPURLResponse {
self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
} else {
}
self.client?.urlProtocolDidFinishLoading(self)
}
})
self.urlTask?.resume()*/
}
override func stopLoading() {
self.urlTask?.cancel()
}
}
URLProtocol.registerClass(AppURLProtocol.self)
}()
#endif
public final class WebAppController: ViewController, AttachmentContainable {
public var requestAttachmentMenuExpansion: () -> Void = { }
public var updateNavigationStack: (@escaping ([AttachmentContainable]) -> ([AttachmentContainable], AttachmentMediaPickerContext?)) -> Void = { _ in }
@ -251,10 +204,6 @@ public final class WebAppController: ViewController, AttachmentContainable {
private var validLayout: (ContainerViewLayout, CGFloat)?
init(context: AccountContext, controller: WebAppController) {
#if DEBUG
let _ = registeredProtocols
#endif
self.context = context
self.controller = controller
self.presentationData = controller.presentationData
@ -271,7 +220,12 @@ public final class WebAppController: ViewController, AttachmentContainable {
self.backgroundColor = self.presentationData.theme.list.plainBackgroundColor
}
let webView = WebAppWebView(account: context.account)
let webView: WebAppWebView
if context.sharedContext.immediateExperimentalUISettings.enablePWA {
webView = WebAppPWAWebViewImpl(account: context.account)
} else {
webView = WebAppWebViewImpl(account: context.account)
}
webView.alpha = 0.0
webView.navigationDelegate = self
webView.uiDelegate = self
@ -479,45 +433,12 @@ public final class WebAppController: ViewController, AttachmentContainable {
webView.scrollView.insertSubview(self.topOverscrollNode.view, at: 0)
}
private func load(url: URL) {
/*#if DEBUG
if "".isEmpty {
if #available(iOS 16.0, *) {
let documentsPath = URL.documentsDirectory.path(percentEncoded: false)
var hasher = SHA256()
var urlString = url.absoluteString
if let range = urlString.firstRange(of: "#") {
urlString.removeSubrange(range.lowerBound...)
}
hasher.update(data: urlString.data(using: .utf8)!)
let digest = Data(hasher.finalize())
let urlHash = hexString(digest)
let cachedFilePath = documentsPath.appending("\(urlHash).bin")
Task {
do {
let data: Data
if let cachedData = try? Data(contentsOf: URL(fileURLWithPath: cachedFilePath)) {
data = cachedData
print("Loaded from cache at \(cachedFilePath)")
} else {
let (loadedData, _) = try await URLSession.shared.data(from: url)
data = loadedData
try loadedData.write(to: URL(fileURLWithPath: cachedFilePath), options: .atomic)
}
self.webView?.load(data, mimeType: "text/html", characterEncodingName: "utf-8", baseURL: url)
} catch let e {
print("\(e)")
}
}
}
return
private func load(url: URL, isMainURL: Bool) {
if isMainURL {
self.webView?.loadMainUrl(url: url)
} else {
self.webView?.load(URLRequest(url: url))
}
#endif*/
self.webView?.load(URLRequest(url: url))
}
func setupWebView() {
@ -528,7 +449,7 @@ public final class WebAppController: ViewController, AttachmentContainable {
if let url = controller.url, controller.source != .menu {
self.queryId = controller.queryId
if let parsedUrl = URL(string: url) {
self.load(url: parsedUrl)
self.load(url: parsedUrl, isMainURL: true)
}
if let keepAliveSignal = controller.keepAliveSignal {
self.keepAliveDisposable = (keepAliveSignal
@ -551,7 +472,7 @@ public final class WebAppController: ViewController, AttachmentContainable {
}
if let parsedUrl = URL(string: result.url) {
strongSelf.queryId = result.queryId
strongSelf.load(url: parsedUrl)
strongSelf.load(url: parsedUrl, isMainURL: true)
}
})
} else {
@ -571,17 +492,21 @@ public final class WebAppController: ViewController, AttachmentContainable {
return
}
self.controller?.titleView?.title = WebAppTitle(title: botApp.title, counter: self.presentationData.strings.WebApp_Miniapp, isVerified: controller.botVerified)
self.load(url: parsedUrl)
self.load(url: parsedUrl, isMainURL: true)
})
})
} else {
let _ = (self.context.engine.messages.requestWebView(peerId: controller.peerId, botId: controller.botId, url: controller.url, payload: controller.payload, themeParams: generateWebAppThemeParams(presentationData.theme), fromMenu: controller.source == .menu, replyToMessageId: controller.replyToMessageId, threadId: controller.threadId)
var enableCached = false
if self.context.sharedContext.immediateExperimentalUISettings.enablePWA {
enableCached = true
}
let _ = (self.context.engine.messages.requestWebView(peerId: controller.peerId, botId: controller.botId, url: controller.url, payload: controller.payload, themeParams: generateWebAppThemeParams(presentationData.theme), fromMenu: controller.source == .menu, replyToMessageId: controller.replyToMessageId, threadId: controller.threadId, enableCached: enableCached)
|> deliverOnMainQueue).start(next: { [weak self] result in
guard let strongSelf = self, let parsedUrl = URL(string: result.url) else {
return
}
strongSelf.queryId = result.queryId
strongSelf.load(url: parsedUrl)
strongSelf.load(url: parsedUrl, isMainURL: true)
if let keepAliveSignal = result.keepAliveSignal {
strongSelf.keepAliveDisposable = (keepAliveSignal

View file

@ -0,0 +1,293 @@
import Foundation
import UIKit
import Display
import WebKit
import SwiftSignalKit
import TelegramCore
import WebWorkerShim
private let findActiveElementY = """
function getOffset(el) {
const rect = el.getBoundingClientRect();
return {
left: rect.left + window.scrollX,
top: rect.top + window.scrollY
};
}
getOffset(document.activeElement).top;
"""
private class WeakGameScriptMessageHandler: NSObject, WKScriptMessageHandler {
private let f: (WKScriptMessage) -> ()
init(_ f: @escaping (WKScriptMessage) -> ()) {
self.f = f
super.init()
}
func userContentController(_ controller: WKUserContentController, didReceive scriptMessage: WKScriptMessage) {
self.f(scriptMessage)
}
}
private class WebViewTouchGestureRecognizer: UITapGestureRecognizer {
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
self.state = .began
}
}
private let eventProxySource = "var TelegramWebviewProxyProto = function() {}; " +
"TelegramWebviewProxyProto.prototype.postEvent = function(eventName, eventData) { " +
"window.webkit.messageHandlers.performAction.postMessage({'eventName': eventName, 'eventData': eventData}); " +
"}; " +
"var TelegramWebviewProxy = new TelegramWebviewProxyProto();"
private let selectionSource = "var css = '*{-webkit-touch-callout:none;} :not(input):not(textarea):not([\"contenteditable\"=\"true\"]){-webkit-user-select:none;}';"
+ " var head = document.head || document.getElementsByTagName('head')[0];"
+ " var style = document.createElement('style'); style.type = 'text/css';" +
" style.appendChild(document.createTextNode(css)); head.appendChild(style);"
private let videoSource = """
document.addEventListener('DOMContentLoaded', () => {
function tgBrowserDisableWebkitEnterFullscreen(videoElement) {
if (videoElement && videoElement.webkitEnterFullscreen) {
videoElement.setAttribute('playsinline', '');
}
}
function tgBrowserDisableFullscreenOnExistingVideos() {
document.querySelectorAll('video').forEach(tgBrowserDisableWebkitEnterFullscreen);
}
function tgBrowserHandleMutations(mutations) {
mutations.forEach((mutation) => {
if (mutation.addedNodes && mutation.addedNodes.length > 0) {
mutation.addedNodes.forEach((newNode) => {
if (newNode.tagName === 'VIDEO') {
tgBrowserDisableWebkitEnterFullscreen(newNode);
}
if (newNode.querySelectorAll) {
newNode.querySelectorAll('video').forEach(tgBrowserDisableWebkitEnterFullscreen);
}
});
}
});
}
tgBrowserDisableFullscreenOnExistingVideos();
const _tgbrowser_observer = new MutationObserver(tgBrowserHandleMutations);
_tgbrowser_observer.observe(document.body, {
childList: true,
subtree: true
});
function tgBrowserDisconnectObserver() {
_tgbrowser_observer.disconnect();
}
});
"""
final class WebAppPWAWebViewImpl: PWAWebView, WebAppWebView {
var handleScriptMessage: (WKScriptMessage) -> Void = { _ in }
var customInsets: UIEdgeInsets = .zero {
didSet {
if self.customInsets != oldValue {
self.setNeedsLayout()
}
}
}
override var safeAreaInsets: UIEdgeInsets {
return UIEdgeInsets(top: self.customInsets.top, left: self.customInsets.left, bottom: self.customInsets.bottom, right: self.customInsets.right)
}
init(account: Account) {
let configuration = WKWebViewConfiguration()
if #available(iOS 17.0, *) {
var uuid: UUID?
if let current = UserDefaults.standard.object(forKey: "TelegramWebStoreUUID_\(account.id.int64)") as? String {
uuid = UUID(uuidString: current)!
} else {
let mainAccountId: Int64
if let current = UserDefaults.standard.object(forKey: "TelegramWebStoreMainAccountId") as? Int64 {
mainAccountId = current
} else {
mainAccountId = account.id.int64
UserDefaults.standard.set(mainAccountId, forKey: "TelegramWebStoreMainAccountId")
}
if account.id.int64 != mainAccountId {
uuid = UUID()
UserDefaults.standard.set(uuid!.uuidString, forKey: "TelegramWebStoreUUID_\(account.id.int64)")
}
}
if let uuid {
configuration.websiteDataStore = WKWebsiteDataStore(forIdentifier: uuid)
}
}
let contentController = WKUserContentController()
var handleScriptMessageImpl: ((WKScriptMessage) -> Void)?
let eventProxyScript = WKUserScript(source: eventProxySource, injectionTime: .atDocumentStart, forMainFrameOnly: false)
contentController.addUserScript(eventProxyScript)
contentController.add(WeakGameScriptMessageHandler { message in
handleScriptMessageImpl?(message)
}, name: "performAction")
let selectionScript = WKUserScript(source: selectionSource, injectionTime: .atDocumentEnd, forMainFrameOnly: true)
contentController.addUserScript(selectionScript)
let videoScript = WKUserScript(source: videoSource, injectionTime: .atDocumentStart, forMainFrameOnly: false)
contentController.addUserScript(videoScript)
configuration.userContentController = contentController
configuration.allowsInlineMediaPlayback = true
configuration.allowsPictureInPictureMediaPlayback = false
if #available(iOS 10.0, *) {
configuration.mediaTypesRequiringUserActionForPlayback = []
} else {
configuration.mediaPlaybackRequiresUserAction = false
}
super.init(frame: CGRect(), configuration: configuration)
self.disablesInteractiveKeyboardGestureRecognizer = true
self.isOpaque = false
self.backgroundColor = .clear
if #available(iOS 9.0, *) {
self.allowsLinkPreview = false
}
if #available(iOS 11.0, *) {
self.scrollView.contentInsetAdjustmentBehavior = .never
}
self.interactiveTransitionGestureRecognizerTest = { point -> Bool in
return point.x > 30.0
}
self.allowsBackForwardNavigationGestures = false
if #available(iOS 16.4, *) {
self.isInspectable = true
}
handleScriptMessageImpl = { [weak self] message in
if let strongSelf = self {
strongSelf.handleScriptMessage(message)
}
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
print()
}
func loadMainUrl(url: URL) {
var url = url
if let range = url.absoluteString.range(of: "#") {
url = URL(string: String(url.absoluteString[..<range.lowerBound])) ?? url
}
self.loadPWA(url: url)
}
override func didMoveToSuperview() {
super.didMoveToSuperview()
if #available(iOS 11.0, *) {
let webScrollView = self.subviews.compactMap { $0 as? UIScrollView }.first
Queue.mainQueue().after(0.1, {
let contentView = webScrollView?.subviews.first(where: { $0.interactions.count > 1 })
guard let dragInteraction = (contentView?.interactions.compactMap { $0 as? UIDragInteraction }.first) else {
return
}
contentView?.removeInteraction(dragInteraction)
})
NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillChangeFrameNotification, object: nil)
NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillShowNotification, object: nil)
NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillHideNotification, object: nil)
}
}
func hideScrollIndicators() {
var hiddenViews: [UIView] = []
for view in self.scrollView.subviews.reversed() {
let minSize = min(view.frame.width, view.frame.height)
if minSize < 4.0 {
view.isHidden = true
hiddenViews.append(view)
}
}
Queue.mainQueue().after(2.0) {
for view in hiddenViews {
view.isHidden = false
}
}
}
func sendEvent(name: String, data: String?) {
let script = "window.TelegramGameProxy && window.TelegramGameProxy.receiveEvent && window.TelegramGameProxy.receiveEvent(\"\(name)\", \(data ?? "null"))"
self.evaluateJavaScript(script, completionHandler: { _, _ in
})
}
func updateMetrics(height: CGFloat, isExpanded: Bool, isStable: Bool, transition: ContainedViewLayoutTransition) {
let viewportData = "{height:\(height), is_expanded:\(isExpanded ? "true" : "false"), is_state_stable:\(isStable ? "true" : "false")}"
self.sendEvent(name: "viewport_changed", data: viewportData)
let safeInsetsData = "{top:\(self.customInsets.top), bottom:\(self.customInsets.bottom), left:\(self.customInsets.left), right:\(self.customInsets.right)}"
self.sendEvent(name: "safe_area_changed", data: safeInsetsData)
}
var lastTouchTimestamp: Double?
private(set) var didTouchOnce = false
var onFirstTouch: () -> Void = {}
func scrollToActiveElement(layout: ContainerViewLayout, completion: @escaping (CGPoint) -> Void, transition: ContainedViewLayoutTransition) {
self.evaluateJavaScript(findActiveElementY, completionHandler: { result, _ in
if let result = result as? CGFloat {
Queue.mainQueue().async {
let convertedY = result - self.scrollView.contentOffset.y
let viewportHeight = self.frame.height
if convertedY < 0.0 || (convertedY + 44.0) > viewportHeight {
let targetOffset: CGFloat
if convertedY < 0.0 {
targetOffset = max(0.0, result - 36.0)
} else {
targetOffset = max(0.0, result + 60.0 - viewportHeight)
}
let contentOffset = CGPoint(x: 0.0, y: targetOffset)
completion(contentOffset)
transition.animateView({
self.scrollView.contentOffset = contentOffset
})
}
}
}
})
}
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
let result = super.hitTest(point, with: event)
self.lastTouchTimestamp = CACurrentMediaTime()
if result != nil && !self.didTouchOnce {
self.didTouchOnce = true
self.onFirstTouch()
}
return result
}
override var inputAccessoryView: UIView? {
return nil
}
}

View file

@ -89,7 +89,21 @@ function tgBrowserDisconnectObserver() {
});
"""
final class WebAppWebView: WKWebView {
protocol WebAppWebView: WKWebView {
var handleScriptMessage: (WKScriptMessage) -> Void { get set }
var customInsets: UIEdgeInsets { get set }
var lastTouchTimestamp: Double? { get set }
var didTouchOnce: Bool { get }
var onFirstTouch: () -> Void { get set }
func sendEvent(name: String, data: String?)
func scrollToActiveElement(layout: ContainerViewLayout, completion: @escaping (CGPoint) -> Void, transition: ContainedViewLayoutTransition)
func updateMetrics(height: CGFloat, isExpanded: Bool, isStable: Bool, transition: ContainedViewLayoutTransition)
func hideScrollIndicators()
func loadMainUrl(url: URL)
}
final class WebAppWebViewImpl: WKWebView, WebAppWebView {
var handleScriptMessage: (WKScriptMessage) -> Void = { _ in }
var customInsets: UIEdgeInsets = .zero {
@ -191,6 +205,10 @@ final class WebAppWebView: WKWebView {
print()
}
func loadMainUrl(url: URL) {
self.load(URLRequest(url: url))
}
override func didMoveToSuperview() {
super.didMoveToSuperview()

View file

@ -0,0 +1,60 @@
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
load(
"@build_bazel_rules_apple//apple:resources.bzl",
"apple_resource_bundle",
"apple_resource_group",
)
load("//build-system/bazel-utils:plist_fragment.bzl",
"plist_fragment",
)
filegroup(
name = "WebWorkerShimResources",
srcs = glob([
"Resources/**/*.js",
]),
visibility = ["//visibility:public"],
)
plist_fragment(
name = "WebWorkerShimBundleInfoPlist",
extension = "plist",
template =
"""
<key>CFBundleIdentifier</key>
<string>org.telegram.WebWorkerShim</string>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleName</key>
<string>WebWorkerShim</string>
"""
)
apple_resource_bundle(
name = "WebWorkerShimBundle",
infoplists = [
":WebWorkerShimBundleInfoPlist",
],
resources = [
":WebWorkerShimResources",
],
)
swift_library(
name = "WebWorkerShim",
module_name = "WebWorkerShim",
srcs = glob([
"Sources/**/*.swift",
]),
data = [
":WebWorkerShimBundle",
],
copts = [
#"-warnings-as-errors",
],
deps = [
],
visibility = [
"//visibility:public",
],
)

View file

@ -0,0 +1,619 @@
(function() {
'use strict';
const eventHandlers = {
install: [],
activate: [],
fetch: []
};
// Helper function for base64 encoding large binary data without stack overflow
function arrayBufferToBase64(buffer) {
const bytes = new Uint8Array(buffer);
const chunkSize = 8192;
let binary = '';
for (let i = 0; i < bytes.length; i += chunkSize) {
const chunk = bytes.subarray(i, i + chunkSize);
binary += String.fromCharCode.apply(null, chunk);
}
return btoa(binary);
}
// ServiceWorkerGlobalScope shim
const swSelf = {
addEventListener: function(event, handler) {
if (eventHandlers[event]) {
eventHandlers[event].push(handler);
}
},
removeEventListener: function(event, handler) {
if (eventHandlers[event]) {
const idx = eventHandlers[event].indexOf(handler);
if (idx !== -1) eventHandlers[event].splice(idx, 1);
}
},
skipWaiting: function() {
return Promise.resolve();
},
clients: {
claim: function() { return Promise.resolve(); },
get: function() { return Promise.resolve(null); },
matchAll: function() { return Promise.resolve([]); },
openWindow: function() { return Promise.resolve(null); }
},
registration: {
scope: '/',
navigationPreload: {
enable: async () => {},
disable: async () => {},
setHeaderValue: async () => {}
}
}
};
// Expose as self
Object.assign(self, swSelf);
// Will be set when SW script is loaded
window.__swOrigin = '';
window.__swScriptURL = '';
// importScripts shim - synchronous script loading is not possible in WebView,
// but we can provide an async polyfill that warns developers
self.importScripts = function(...urls) {
console.warn('[SW] importScripts called but synchronous script loading is not supported in this polyfill. Scripts:', urls);
// This is a limitation - real importScripts is synchronous
// We could potentially pre-load scripts during SW registration
throw new Error('importScripts is not supported in this service worker polyfill');
};
// Resolve a URL relative to the SW script location
function resolveRelativeURL(url) {
if (!url) return url;
// Already absolute
if (url.startsWith('http://') || url.startsWith('https://')) {
return url;
}
// Use standard URL resolution with script URL as base
if (window.__swScriptURL) {
try {
return new URL(url, window.__swScriptURL).href;
} catch (e) {
console.error('[SW] Failed to resolve URL:', url, e);
}
}
// Fallback: prepend origin
if (url.startsWith('/')) {
return window.__swOrigin + url;
}
return window.__swOrigin + '/' + url;
}
// Cache API bridge
class CacheShim {
constructor(name, origin) {
this.name = name;
this.origin = origin;
}
_normalizeUrl(request) {
const url = typeof request === 'string' ? request : request.url;
return resolveRelativeURL(url);
}
async match(request) {
const url = this._normalizeUrl(request);
console.log('[SW Cache] match:', url, 'in cache:', this.name);
return new Promise((resolve) => {
const callbackId = 'cache_' + Date.now() + '_' + Math.random();
window.__cacheCallbacks = window.__cacheCallbacks || {};
const timeoutId = setTimeout(() => {
console.log('[SW Cache] match timeout for:', url);
delete window.__cacheCallbacks[callbackId];
resolve(undefined);
}, 10000);
window.__cacheCallbacks[callbackId] = (response) => {
clearTimeout(timeoutId);
delete window.__cacheCallbacks[callbackId];
if (response) {
console.log('[SW Cache] match HIT:', url);
resolve(new Response(
Uint8Array.from(atob(response.bodyBase64), c => c.charCodeAt(0)),
{
status: response.status,
statusText: response.statusText,
headers: response.headers
}
));
} else {
console.log('[SW Cache] match MISS:', url);
resolve(undefined);
}
};
webkit.messageHandlers.swBridge.postMessage({
type: 'cacheMatch',
callbackId: callbackId,
origin: this.origin,
cacheName: this.name,
url: url
});
});
}
async put(request, response) {
const url = this._normalizeUrl(request);
const clonedResponse = response.clone();
const body = await clonedResponse.arrayBuffer();
const bodyBase64 = arrayBufferToBase64(body);
const headers = {};
clonedResponse.headers.forEach((value, key) => {
headers[key] = value;
});
webkit.messageHandlers.swBridge.postMessage({
type: 'cachePut',
origin: this.origin,
cacheName: this.name,
url: url,
response: {
status: clonedResponse.status,
statusText: clonedResponse.statusText,
headers: headers,
bodyBase64: bodyBase64
}
});
}
async add(request) {
const url = this._normalizeUrl(request);
const response = await fetch(url);
if (!response.ok) {
throw new TypeError('Bad response status');
}
return this.put(url, response);
}
async addAll(requests) {
const urls = requests.map(r => this._normalizeUrl(r));
await Promise.all(urls.map(url => this.add(url)));
}
async delete(request) {
const url = this._normalizeUrl(request);
return new Promise((resolve) => {
const callbackId = 'cache_del_' + Date.now() + '_' + Math.random();
window.__cacheCallbacks = window.__cacheCallbacks || {};
const timeoutId = setTimeout(() => {
delete window.__cacheCallbacks[callbackId];
resolve(false);
}, 10000);
window.__cacheCallbacks[callbackId] = (result) => {
clearTimeout(timeoutId);
delete window.__cacheCallbacks[callbackId];
resolve(result);
};
webkit.messageHandlers.swBridge.postMessage({
type: 'cacheEntryDelete',
callbackId: callbackId,
origin: this.origin,
cacheName: this.name,
url: url
});
});
}
async keys() {
return []; // Simplified for now
}
}
class CacheStorageShim {
constructor(origin) {
this.origin = origin;
this._caches = {};
}
async open(name) {
if (!this._caches[name]) {
webkit.messageHandlers.swBridge.postMessage({
type: 'cacheOpen',
origin: this.origin,
cacheName: name
});
this._caches[name] = new CacheShim(name, this.origin);
}
return this._caches[name];
}
async has(name) {
const keys = await this.keys();
return keys.includes(name);
}
async delete(name) {
delete this._caches[name];
return new Promise((resolve) => {
const callbackId = 'caches_del_' + Date.now() + '_' + Math.random();
window.__cacheCallbacks = window.__cacheCallbacks || {};
const timeoutId = setTimeout(() => {
delete window.__cacheCallbacks[callbackId];
resolve(false);
}, 10000);
window.__cacheCallbacks[callbackId] = (result) => {
clearTimeout(timeoutId);
delete window.__cacheCallbacks[callbackId];
resolve(result);
};
webkit.messageHandlers.swBridge.postMessage({
type: 'cacheStorageDelete',
callbackId: callbackId,
origin: this.origin,
cacheName: name
});
});
}
async keys() {
return new Promise((resolve) => {
const callbackId = 'caches_keys_' + Date.now() + '_' + Math.random();
window.__cacheCallbacks = window.__cacheCallbacks || {};
const timeoutId = setTimeout(() => {
delete window.__cacheCallbacks[callbackId];
resolve(Object.keys(this._caches)); // Fall back to local
}, 10000);
window.__cacheCallbacks[callbackId] = (names) => {
clearTimeout(timeoutId);
delete window.__cacheCallbacks[callbackId];
// Sync local cache map with actual stored caches
(names || []).forEach(n => {
if (!this._caches[n]) {
this._caches[n] = new CacheShim(n, this.origin);
}
});
resolve(names || []);
};
webkit.messageHandlers.swBridge.postMessage({
type: 'cachesKeys',
callbackId: callbackId,
origin: this.origin
});
});
}
async match(request) {
const url = typeof request === 'string' ? request : request.url;
for (const cache of Object.values(this._caches)) {
const response = await cache.match(url);
if (response) return response;
}
return undefined;
}
}
Object.defineProperty(self, 'caches', {
get: function() {
if (!window.__cachesInstance) {
window.__cachesInstance = new CacheStorageShim(window.__swOrigin);
}
return window.__cachesInstance;
}
});
// FetchEvent shim
class FetchEvent {
constructor(request, requestId) {
this.request = request;
this.requestId = requestId;
this._responded = false;
this._responseSent = false; // Tracks if response actually sent to native
this._waitUntilPromises = [];
}
respondWith(responsePromise) {
this._responded = true;
console.log('[SW] respondWith called for:', this.request.url);
Promise.resolve(responsePromise).then(async (response) => {
// Prevent double-response
if (this._responseSent) {
console.log('[SW] Response already sent, ignoring duplicate');
return;
}
this._responseSent = true;
console.log('[SW] Response received:', response.status, response.url || 'synthetic');
const cloned = response.clone();
const body = await cloned.arrayBuffer();
const bodyBase64 = arrayBufferToBase64(body);
const headers = {};
cloned.headers.forEach((value, key) => {
headers[key] = value;
});
webkit.messageHandlers.swBridge.postMessage({
type: 'fetchResponse',
requestId: this.requestId,
response: {
status: cloned.status,
statusText: cloned.statusText,
headers: headers,
bodyBase64: bodyBase64
}
});
}).catch((error) => {
// Prevent double-response
if (this._responseSent) {
console.log('[SW] Response already sent, ignoring error');
return;
}
this._responseSent = true;
console.log('[SW] respondWith error:', error.message);
webkit.messageHandlers.swBridge.postMessage({
type: 'fetchResponse',
requestId: this.requestId,
error: error.message
});
});
}
waitUntil(promise) {
this._waitUntilPromises.push(promise);
}
}
// ExtendableEvent shim
class ExtendableEvent {
constructor(type) {
this.type = type;
this._waitUntilPromises = [];
}
waitUntil(promise) {
this._waitUntilPromises.push(promise);
}
}
// Global dispatch functions called from Swift
window.dispatchFetchEvent = function(requestId, url, method, headers, bodyBase64, options) {
console.log('[SW] dispatchFetchEvent:', url, 'method:', method);
// Build request init
const init = {
method: method,
headers: headers
};
// Add body for methods that support it (not GET/HEAD)
if (bodyBase64 && method !== 'GET' && method !== 'HEAD') {
const bodyBytes = Uint8Array.from(atob(bodyBase64), c => c.charCodeAt(0));
init.body = bodyBytes;
}
// Add request options if provided
if (options) {
if (options.credentials) init.credentials = options.credentials;
if (options.mode) init.mode = options.mode;
if (options.redirect) init.redirect = options.redirect;
if (options.cache) init.cache = options.cache;
}
const request = new Request(url, init);
const event = new FetchEvent(request, requestId);
for (const handler of eventHandlers.fetch) {
handler(event);
}
// If no handler called respondWith, tell Swift to pass through to network
// Use a longer timeout (50ms) to allow for async handlers like:
// event.respondWith((async () => { ... })())
// Also check _responseSent to prevent double-sends
setTimeout(() => {
if (!event._responded && !event._responseSent) {
event._responseSent = true;
console.log('[SW] No handler responded for:', url);
webkit.messageHandlers.swBridge.postMessage({
type: 'fetchResponse',
requestId: requestId,
passthrough: true
});
}
}, 50);
};
window.dispatchInstallEvent = function() {
return new Promise((resolve) => {
const event = new ExtendableEvent('install');
for (const handler of eventHandlers.install) {
handler(event);
}
Promise.all(event._waitUntilPromises).then(() => resolve(true)).catch(() => resolve(false));
});
};
window.dispatchActivateEvent = function() {
return new Promise((resolve) => {
const event = new ExtendableEvent('activate');
for (const handler of eventHandlers.activate) {
handler(event);
}
Promise.all(event._waitUntilPromises).then(() => resolve(true)).catch(() => resolve(false));
});
};
window.setOrigin = function(origin, scriptURL) {
window.__swOrigin = origin;
window.__swScriptURL = scriptURL || '';
// Set up self.location to reflect the SW script URL
if (scriptURL) {
try {
const url = new URL(scriptURL);
const locationShim = {
href: scriptURL,
origin: url.origin,
protocol: url.protocol,
host: url.host,
hostname: url.hostname,
port: url.port,
pathname: url.pathname,
search: url.search,
hash: url.hash,
toString: function() { return scriptURL; }
};
// Override self.location (it's about:blank by default)
Object.defineProperty(self, 'location', {
value: locationShim,
writable: false,
configurable: true
});
} catch (e) {
console.error('[SW] Failed to set location:', e);
}
}
};
// Override fetch to route requests through native handler
// This is needed because the SW context WebView can't make real network requests
window.__fetchCallbacks = window.__fetchCallbacks || {};
window.resolveSWFetchCallback = function(requestId, response, error) {
if (window.__fetchCallbacks && window.__fetchCallbacks[requestId]) {
window.__fetchCallbacks[requestId](response, error);
delete window.__fetchCallbacks[requestId];
}
};
const originalFetch = window.fetch;
window.fetch = function(input, init) {
let url;
let method = 'GET';
let headers = {};
if (typeof input === 'string') {
url = input;
} else if (input instanceof Request) {
url = input.url;
method = input.method;
input.headers.forEach((value, key) => { headers[key] = value; });
} else if (input instanceof URL) {
url = input.href;
} else {
return originalFetch.call(window, input, init);
}
if (init) {
method = init.method || method;
if (init.headers) {
if (init.headers instanceof Headers) {
init.headers.forEach((value, key) => { headers[key] = value; });
} else {
Object.assign(headers, init.headers);
}
}
}
// Resolve relative URLs
url = resolveRelativeURL(url);
// Helper to send the request to native
function sendRequest(bodyBase64) {
return new Promise((resolve, reject) => {
const requestId = 'swfetch_' + Date.now() + '_' + Math.random();
const timeoutId = setTimeout(() => {
delete window.__fetchCallbacks[requestId];
reject(new TypeError('Network request failed (timeout)'));
}, 30000);
window.__fetchCallbacks[requestId] = (response, error) => {
clearTimeout(timeoutId);
if (error) {
reject(new TypeError(error));
} else if (response) {
const bodyData = response.bodyBase64
? Uint8Array.from(atob(response.bodyBase64), c => c.charCodeAt(0))
: new Uint8Array(0);
resolve(new Response(bodyData, {
status: response.status || 200,
statusText: response.statusText || 'OK',
headers: response.headers || {}
}));
} else {
reject(new TypeError('Network request failed'));
}
};
webkit.messageHandlers.swBridge.postMessage({
type: 'swFetch',
requestId: requestId,
url: url,
method: method,
headers: headers,
bodyBase64: bodyBase64,
origin: window.__swOrigin
});
});
}
// For GET/HEAD, no body allowed
if (method === 'GET' || method === 'HEAD') {
return sendRequest(null);
}
// Handle body from init
if (init && init.body !== undefined && init.body !== null) {
const body = init.body;
if (typeof body === 'string') {
return sendRequest(arrayBufferToBase64(new TextEncoder().encode(body).buffer));
} else if (body instanceof ArrayBuffer) {
return sendRequest(arrayBufferToBase64(body));
} else if (ArrayBuffer.isView(body)) {
return sendRequest(arrayBufferToBase64(body.buffer));
} else if (body instanceof URLSearchParams) {
return sendRequest(arrayBufferToBase64(new TextEncoder().encode(body.toString()).buffer));
} else if (body instanceof Blob) {
return body.arrayBuffer().then(ab => sendRequest(arrayBufferToBase64(ab)));
}
}
// Handle body from Request object
if (input instanceof Request && input.body) {
return input.clone().arrayBuffer()
.then(ab => sendRequest(arrayBufferToBase64(ab)))
.catch(() => sendRequest(null));
}
// No body
return sendRequest(null);
};
window.resolveCacheCallback = function(callbackId, response) {
if (window.__cacheCallbacks && window.__cacheCallbacks[callbackId]) {
window.__cacheCallbacks[callbackId](response);
}
};
// Signal ready
webkit.messageHandlers.swBridge.postMessage({ type: 'contextReady' });
})();

View file

@ -0,0 +1,478 @@
(function() {
'use strict';
if (window.__pwaShimInstalled) return;
window.__pwaShimInstalled = true;
const registrations = new Map();
// Helper function for base64 encoding large binary data without stack overflow
function arrayBufferToBase64(buffer) {
const bytes = new Uint8Array(buffer);
const chunkSize = 8192;
let binary = '';
for (let i = 0; i < bytes.length; i += chunkSize) {
const chunk = bytes.subarray(i, i + chunkSize);
binary += String.fromCharCode.apply(null, chunk);
}
return btoa(binary);
}
// Get origin from current page URL
function getCurrentOrigin() {
return location.origin;
}
// Callback system for async cache operations
window.__cacheCallbacks = window.__cacheCallbacks || {};
window.resolveCacheCallback = function(callbackId, response) {
if (window.__cacheCallbacks && window.__cacheCallbacks[callbackId]) {
window.__cacheCallbacks[callbackId](response);
}
};
// Cache API shim for page context
class CacheShim {
constructor(name, origin) {
this.name = name;
this.origin = origin;
}
async match(request) {
const url = this._normalizeUrl(request);
return new Promise((resolve) => {
const callbackId = 'cache_' + Date.now() + '_' + Math.random();
const timeoutId = setTimeout(() => {
delete window.__cacheCallbacks[callbackId];
resolve(undefined);
}, 10000);
window.__cacheCallbacks[callbackId] = (response) => {
clearTimeout(timeoutId);
delete window.__cacheCallbacks[callbackId];
if (response) {
resolve(new Response(
Uint8Array.from(atob(response.bodyBase64), c => c.charCodeAt(0)),
{
status: response.status,
statusText: response.statusText,
headers: response.headers
}
));
} else {
resolve(undefined);
}
};
webkit.messageHandlers.swBridge.postMessage({
type: 'cacheMatch',
callbackId: callbackId,
origin: this.origin,
cacheName: this.name,
url: url
});
});
}
async put(request, response) {
const url = this._normalizeUrl(request);
const clonedResponse = response.clone();
const body = await clonedResponse.arrayBuffer();
const bodyBase64 = arrayBufferToBase64(body);
const headers = {};
clonedResponse.headers.forEach((value, key) => {
headers[key] = value;
});
webkit.messageHandlers.swBridge.postMessage({
type: 'cachePut',
origin: this.origin,
cacheName: this.name,
url: url,
response: {
status: clonedResponse.status,
statusText: clonedResponse.statusText,
headers: headers,
bodyBase64: bodyBase64
}
});
}
async add(request) {
const url = this._normalizeUrl(request);
const response = await fetch(url);
if (!response.ok) {
throw new TypeError('Bad response status');
}
return this.put(url, response);
}
async addAll(requests) {
const urls = requests.map(r => this._normalizeUrl(r));
await Promise.all(urls.map(url => this.add(url)));
}
async delete(request) {
const url = this._normalizeUrl(request);
return new Promise((resolve) => {
const callbackId = 'cache_del_' + Date.now() + '_' + Math.random();
const timeoutId = setTimeout(() => {
delete window.__cacheCallbacks[callbackId];
resolve(false);
}, 10000);
window.__cacheCallbacks[callbackId] = (result) => {
clearTimeout(timeoutId);
delete window.__cacheCallbacks[callbackId];
resolve(result);
};
webkit.messageHandlers.swBridge.postMessage({
type: 'cacheEntryDelete',
callbackId: callbackId,
origin: this.origin,
cacheName: this.name,
url: url
});
});
}
async keys() {
return new Promise((resolve) => {
const callbackId = 'cache_keys_' + Date.now() + '_' + Math.random();
const timeoutId = setTimeout(() => {
delete window.__cacheCallbacks[callbackId];
resolve([]);
}, 10000);
window.__cacheCallbacks[callbackId] = (urls) => {
clearTimeout(timeoutId);
delete window.__cacheCallbacks[callbackId];
resolve((urls || []).map(url => new Request(url)));
};
webkit.messageHandlers.swBridge.postMessage({
type: 'cacheKeys',
callbackId: callbackId,
origin: this.origin,
cacheName: this.name
});
});
}
_normalizeUrl(request) {
const url = typeof request === 'string' ? request : request.url;
// Convert relative URLs to absolute
return new URL(url, location.href).href;
}
}
class CacheStorageShim {
constructor() {
this._caches = {};
}
get origin() {
return getCurrentOrigin();
}
async open(name) {
if (!this._caches[name]) {
webkit.messageHandlers.swBridge.postMessage({
type: 'cacheOpen',
origin: this.origin,
cacheName: name
});
this._caches[name] = new CacheShim(name, this.origin);
}
return this._caches[name];
}
async has(name) {
return this._caches.hasOwnProperty(name);
}
async delete(name) {
delete this._caches[name];
return new Promise((resolve) => {
const callbackId = 'caches_del_' + Date.now() + '_' + Math.random();
const timeoutId = setTimeout(() => {
delete window.__cacheCallbacks[callbackId];
resolve(false);
}, 10000);
window.__cacheCallbacks[callbackId] = (result) => {
clearTimeout(timeoutId);
delete window.__cacheCallbacks[callbackId];
resolve(result);
};
webkit.messageHandlers.swBridge.postMessage({
type: 'cacheStorageDelete',
callbackId: callbackId,
origin: this.origin,
cacheName: name
});
});
}
async keys() {
return new Promise((resolve) => {
const callbackId = 'caches_keys_' + Date.now() + '_' + Math.random();
const timeoutId = setTimeout(() => {
delete window.__cacheCallbacks[callbackId];
resolve([]);
}, 10000);
window.__cacheCallbacks[callbackId] = (names) => {
clearTimeout(timeoutId);
delete window.__cacheCallbacks[callbackId];
(names || []).forEach(n => {
if (!this._caches[n]) {
this._caches[n] = new CacheShim(n, this.origin);
}
});
resolve(names || []);
};
webkit.messageHandlers.swBridge.postMessage({
type: 'cachesKeys',
callbackId: callbackId,
origin: this.origin
});
});
}
async match(request) {
const url = typeof request === 'string' ? request : request.url;
for (const cache of Object.values(this._caches)) {
const response = await cache.match(url);
if (response) return response;
}
return undefined;
}
}
// Install the caches shim
const cachesShim = new CacheStorageShim();
Object.defineProperty(window, 'caches', {
value: cachesShim,
writable: false,
configurable: false
});
const mockServiceWorker = {
state: 'activated',
scriptURL: '',
addEventListener: function() {},
removeEventListener: function() {},
postMessage: function() {}
};
function createMockRegistration(scope, scriptURL) {
const scopePath = new URL(scope, location.origin).pathname;
return {
scope: scope,
installing: null,
waiting: null,
active: Object.assign({}, mockServiceWorker, { scriptURL: scriptURL }),
navigationPreload: {
enable: async () => {},
disable: async () => {},
setHeaderValue: async () => {},
getState: async () => ({ enabled: false, headerValue: '' })
},
addEventListener: function() {},
removeEventListener: function() {},
update: async function() { return this; },
unregister: async function() {
return new Promise((resolve) => {
const callbackId = 'unreg_' + Date.now() + '_' + Math.random();
const timeoutId = setTimeout(() => {
delete window.__registrationCallbacks[callbackId];
// Still remove from local map even on timeout
registrations.delete(scopePath);
resolve(true);
}, 10000);
window.__registrationCallbacks[callbackId] = (success) => {
clearTimeout(timeoutId);
registrations.delete(scopePath);
resolve(success);
};
webkit.messageHandlers.swBridge.postMessage({
type: 'unregister',
callbackId: callbackId,
scope: scopePath
});
});
},
showNotification: async function() {},
getNotifications: async function() { return []; }
};
}
// Pending registration callbacks
window.__registrationCallbacks = window.__registrationCallbacks || {};
window.resolveRegistrationCallback = function(callbackId, success, error) {
if (window.__registrationCallbacks && window.__registrationCallbacks[callbackId]) {
window.__registrationCallbacks[callbackId](success, error);
delete window.__registrationCallbacks[callbackId];
}
};
// State update handler - native sends this when SW state changes
window.updateSWState = function(scope, state) {
const registration = registrations.get(scope);
if (registration) {
if (state === 'installing') {
registration.installing = Object.assign({}, mockServiceWorker, { scriptURL: registration.active?.scriptURL || '' });
registration.active = null;
} else if (state === 'installed') {
registration.waiting = registration.installing;
registration.installing = null;
} else if (state === 'activated') {
registration.active = registration.waiting || registration.installing;
registration.installing = null;
registration.waiting = null;
}
}
};
navigator.serviceWorker = {
register: async function(scriptURL, options) {
// Resolve script URL to absolute
const absoluteScriptURL = new URL(scriptURL, location.href).href;
// Resolve scope - store as absolute URL for correct matching
let scopeURL;
if (options?.scope) {
scopeURL = new URL(options.scope, location.href).href;
} else {
// Default: directory of the SW script
const scriptPath = new URL(absoluteScriptURL).pathname;
const scopePath = scriptPath.substring(0, scriptPath.lastIndexOf('/') + 1);
scopeURL = new URL(scopePath, location.origin).href;
}
const scopePath = new URL(scopeURL).pathname;
return new Promise((resolve, reject) => {
const callbackId = 'reg_' + Date.now() + '_' + Math.random();
const timeoutId = setTimeout(() => {
delete window.__registrationCallbacks[callbackId];
reject(new TypeError('Service worker registration timed out'));
}, 30000);
window.__registrationCallbacks[callbackId] = (success, error) => {
clearTimeout(timeoutId);
if (success) {
const registration = createMockRegistration(scopeURL, absoluteScriptURL);
// Start with installing state
registration.installing = Object.assign({}, mockServiceWorker, { scriptURL: absoluteScriptURL });
registration.active = null;
registrations.set(scopePath, registration);
resolve(registration);
} else {
reject(new TypeError(error || 'Service worker registration failed'));
}
};
webkit.messageHandlers.swBridge.postMessage({
type: 'register',
callbackId: callbackId,
scriptURL: absoluteScriptURL,
scope: scopePath
});
});
},
getRegistration: async function(scope) {
if (!scope) {
// No scope provided - return registration matching current page
const pageURL = new URL(location.href);
for (const [regScope, reg] of registrations) {
// Check if current page is within this scope
if (pageURL.pathname.startsWith(new URL(reg.scope).pathname)) {
return reg;
}
}
return undefined;
}
// Normalize scope to absolute URL for lookup
let normalizedScope;
try {
normalizedScope = new URL(scope, location.href).pathname;
} catch (e) {
normalizedScope = scope;
}
// Try direct match first, then path match
if (registrations.has(normalizedScope)) {
return registrations.get(normalizedScope);
}
// Try matching as full URL
for (const [regScope, reg] of registrations) {
if (reg.scope === scope || new URL(reg.scope).pathname === normalizedScope) {
return reg;
}
}
return undefined;
},
getRegistrations: async function() {
return Array.from(registrations.values());
},
get ready() {
return new Promise((resolve) => {
const check = () => {
// Find a registration with an active worker
for (const reg of registrations.values()) {
if (reg.active) {
resolve(reg);
return;
}
}
setTimeout(check, 50);
};
setTimeout(check, 100);
});
},
get controller() {
// Controller should only be non-null if there's an active SW that controls this page
for (const reg of registrations.values()) {
if (reg.active) {
return reg.active;
}
}
return null;
},
addEventListener: function() {},
removeEventListener: function() {}
};
Object.defineProperty(navigator, 'serviceWorker', {
value: navigator.serviceWorker,
writable: false,
configurable: false
});
// Note: No custom fetch override needed - NSURLProtocol handles all requests
})();

View file

@ -0,0 +1,282 @@
import Foundation
import CryptoKit
class CachePersistence {
static let shared = CachePersistence()
let baseDirectory: URL
private let ioQueue = DispatchQueue(label: "com.workershim.persistence", qos: .utility)
init(baseDirectory: URL? = nil) {
if let dir = baseDirectory {
self.baseDirectory = dir
} else {
let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
self.baseDirectory = appSupport.appendingPathComponent("WorkerShim_v5")
}
// Create base directory if needed
try? FileManager.default.createDirectory(at: self.baseDirectory, withIntermediateDirectories: true)
}
// MARK: - Helpers
func originHash(_ origin: String) -> String {
let data = Data(origin.utf8)
let hash = SHA256.hash(data: data)
return hash.prefix(16).map { String(format: "%02x", $0) }.joined()
}
func originDirectory(for origin: String) -> URL {
baseDirectory.appendingPathComponent(originHash(origin))
}
// MARK: - Registration Persistence
func saveRegistration(_ snapshot: RegistrationSnapshot) {
ioQueue.async { [weak self] in
guard let self = self else { return }
let originDir = self.originDirectory(for: snapshot.origin)
try? FileManager.default.createDirectory(at: originDir, withIntermediateDirectories: true)
// Save origin.txt for debugging
let originFile = originDir.appendingPathComponent("origin.txt")
try? snapshot.origin.write(to: originFile, atomically: true, encoding: .utf8)
// Save registration.json
let registrationFile = originDir.appendingPathComponent("registration.json")
do {
let encoder = JSONEncoder()
encoder.outputFormatting = .prettyPrinted
let data = try encoder.encode(snapshot)
try data.write(to: registrationFile, options: .atomic)
} catch {
print("[CachePersistence] Failed to save registration: \(error)")
}
}
}
func loadAllRegistrations() -> [RegistrationSnapshot] {
var registrations: [RegistrationSnapshot] = []
guard let contents = try? FileManager.default.contentsOfDirectory(
at: baseDirectory,
includingPropertiesForKeys: nil
) else {
return registrations
}
for originDir in contents {
let registrationFile = originDir.appendingPathComponent("registration.json")
guard let data = try? Data(contentsOf: registrationFile),
let snapshot = try? JSONDecoder().decode(RegistrationSnapshot.self, from: data) else {
continue
}
registrations.append(snapshot)
}
return registrations
}
// Returns list of origins without creating full registration objects
func loadAllOrigins() -> [String] {
var origins: [String] = []
guard let contents = try? FileManager.default.contentsOfDirectory(
at: baseDirectory,
includingPropertiesForKeys: nil
) else {
return origins
}
for originDir in contents {
// Read origin.txt for the actual origin string
let originFile = originDir.appendingPathComponent("origin.txt")
if let origin = try? String(contentsOf: originFile, encoding: .utf8) {
origins.append(origin.trimmingCharacters(in: .whitespacesAndNewlines))
}
}
return origins
}
// MARK: - Script Persistence
func saveScript(_ script: String, for origin: String) {
ioQueue.async { [weak self] in
guard let self = self else { return }
let originDir = self.originDirectory(for: origin)
try? FileManager.default.createDirectory(at: originDir, withIntermediateDirectories: true)
let scriptFile = originDir.appendingPathComponent("sw-script.js")
do {
try script.write(to: scriptFile, atomically: true, encoding: .utf8)
} catch {
print("[CachePersistence] Failed to save script: \(error)")
}
}
}
func loadScript(for origin: String) -> String? {
let scriptFile = originDirectory(for: origin).appendingPathComponent("sw-script.js")
return try? String(contentsOf: scriptFile, encoding: .utf8)
}
// MARK: - Cache Entry Persistence
struct CacheManifestEntry: Codable {
let bodyFile: String
let status: Int
let statusText: String
let headers: [String: String]
}
struct CacheManifest: Codable {
var entries: [String: CacheManifestEntry] = [:]
}
func saveCacheEntry(origin: String, cacheName: String, url: String, response: CachedResponse) {
ioQueue.async { [weak self] in
guard let self = self else { return }
let cacheDir = self.originDirectory(for: origin)
.appendingPathComponent("caches")
.appendingPathComponent(cacheName)
let bodiesDir = cacheDir.appendingPathComponent("bodies")
try? FileManager.default.createDirectory(at: bodiesDir, withIntermediateDirectories: true)
// Generate body filename from URL hash
let urlHash = self.originHash(url)
let bodyFile = "\(urlHash).body"
let bodyPath = bodiesDir.appendingPathComponent(bodyFile)
// Write body
do {
try response.body.write(to: bodyPath, options: .atomic)
} catch {
print("[CachePersistence] Failed to write body: \(error)")
return
}
// Update manifest
let manifestPath = cacheDir.appendingPathComponent("manifest.json")
var manifest = self.loadManifest(at: manifestPath)
manifest.entries[url] = CacheManifestEntry(
bodyFile: bodyFile,
status: response.status,
statusText: response.statusText,
headers: response.headers
)
do {
let encoder = JSONEncoder()
encoder.outputFormatting = .prettyPrinted
let data = try encoder.encode(manifest)
try data.write(to: manifestPath, options: .atomic)
} catch {
print("[CachePersistence] Failed to write manifest: \(error)")
}
}
}
private func loadManifest(at path: URL) -> CacheManifest {
guard let data = try? Data(contentsOf: path),
let manifest = try? JSONDecoder().decode(CacheManifest.self, from: data) else {
return CacheManifest()
}
return manifest
}
func loadCache(origin: String) -> [String: [String: CachedResponse]] {
var result: [String: [String: CachedResponse]] = [:]
let cachesDir = originDirectory(for: origin).appendingPathComponent("caches")
guard let cacheNames = try? FileManager.default.contentsOfDirectory(
at: cachesDir,
includingPropertiesForKeys: nil
) else {
return result
}
for cacheDir in cacheNames {
let cacheName = cacheDir.lastPathComponent
let manifestPath = cacheDir.appendingPathComponent("manifest.json")
let manifest = loadManifest(at: manifestPath)
var cacheEntries: [String: CachedResponse] = [:]
for (url, entry) in manifest.entries {
let bodyPath = cacheDir.appendingPathComponent("bodies").appendingPathComponent(entry.bodyFile)
guard let body = try? Data(contentsOf: bodyPath) else {
continue
}
cacheEntries[url] = CachedResponse(
url: url,
status: entry.status,
statusText: entry.statusText,
headers: entry.headers,
body: body
)
}
if !cacheEntries.isEmpty {
result[cacheName] = cacheEntries
}
}
return result
}
// MARK: - Delete Operations
func deleteCacheEntry(origin: String, cacheName: String, url: String) {
ioQueue.async { [weak self] in
guard let self = self else { return }
let cacheDir = self.originDirectory(for: origin)
.appendingPathComponent("caches")
.appendingPathComponent(cacheName)
let manifestPath = cacheDir.appendingPathComponent("manifest.json")
var manifest = self.loadManifest(at: manifestPath)
guard let entry = manifest.entries.removeValue(forKey: url) else { return }
// Delete body file
let bodyPath = cacheDir.appendingPathComponent("bodies").appendingPathComponent(entry.bodyFile)
try? FileManager.default.removeItem(at: bodyPath)
// Update manifest
do {
let encoder = JSONEncoder()
encoder.outputFormatting = .prettyPrinted
let data = try encoder.encode(manifest)
try data.write(to: manifestPath, options: .atomic)
} catch {
print("[CachePersistence] Failed to update manifest: \(error)")
}
}
}
func deleteCache(origin: String, name: String) {
ioQueue.async { [weak self] in
guard let self = self else { return }
let cacheDir = self.originDirectory(for: origin)
.appendingPathComponent("caches")
.appendingPathComponent(name)
try? FileManager.default.removeItem(at: cacheDir)
}
}
func deleteOrigin(_ origin: String) {
ioQueue.async { [weak self] in
guard let self = self else { return }
let originDir = self.originDirectory(for: origin)
try? FileManager.default.removeItem(at: originDir)
}
}
}

View file

@ -0,0 +1,150 @@
import Foundation
struct CachedResponse: Codable {
let url: String
let status: Int
let statusText: String
let headers: [String: String]
let body: Data
func toDictionary() -> [String: Any] {
return [
"url": url,
"status": status,
"statusText": statusText,
"headers": headers,
"bodyBase64": body.base64EncodedString()
]
}
}
class CacheStorage {
static let shared = CacheStorage()
// [origin: [cacheName: [url: CachedResponse]]]
private var storage: [String: [String: [String: CachedResponse]]] = [:]
private let queue = DispatchQueue(label: "com.workershim.cache", attributes: .concurrent)
// Persistence layer (optional, nil during some tests)
var persistence: CachePersistence?
private init() {}
// MARK: - Persistence Integration
func loadFromDisk() {
guard let persistence = persistence else { return }
// Load all origins (without creating ServiceWorkerRegistration objects
// which have deallocation issues)
let origins = persistence.loadAllOrigins()
for origin in origins {
let caches = persistence.loadCache(origin: origin)
queue.async(flags: .barrier) {
if self.storage[origin] == nil {
self.storage[origin] = [:]
}
for (cacheName, entries) in caches {
self.storage[origin]?[cacheName] = entries
}
}
}
}
func open(origin: String, name: String) {
queue.async(flags: .barrier) {
if self.storage[origin] == nil {
self.storage[origin] = [:]
}
if self.storage[origin]![name] == nil {
self.storage[origin]![name] = [:]
}
}
}
func keys(origin: String) -> [String] {
queue.sync {
guard let originStorage = self.storage[origin] else { return [] }
return Array(originStorage.keys)
}
}
func delete(origin: String, name: String) -> Bool {
let result = queue.sync(flags: .barrier) {
return self.storage[origin]?.removeValue(forKey: name) != nil
}
persistence?.deleteCache(origin: origin, name: name)
return result
}
func put(origin: String, cacheName: String, url: String, response: CachedResponse) {
print("[CacheStorage] PUT origin: \(origin), cache: \(cacheName), url: \(url)")
queue.async(flags: .barrier) {
// Ensure nested structure exists
if self.storage[origin] == nil {
self.storage[origin] = [:]
}
if self.storage[origin]![cacheName] == nil {
self.storage[origin]![cacheName] = [:]
}
self.storage[origin]![cacheName]![url] = response
}
persistence?.saveCacheEntry(origin: origin, cacheName: cacheName, url: url, response: response)
}
func match(origin: String, cacheName: String?, url: String) -> CachedResponse? {
queue.sync {
print("[CacheStorage] match origin: \(origin), url: \(url)")
if let caches = self.storage[origin] {
print("[CacheStorage] Available caches for origin: \(caches.keys)")
for (name, cache) in caches {
print("[CacheStorage] Cache '\(name)' has \(cache.count) entries: \(Array(cache.keys).prefix(5))")
}
} else {
print("[CacheStorage] No caches for origin \(origin)")
}
if let cacheName = cacheName {
return self.storage[origin]?[cacheName]?[url]
}
// Search all caches for this origin
guard let caches = self.storage[origin] else { return nil }
for (_, cache) in caches {
if let response = cache[url] {
return response
}
}
return nil
}
}
func deleteEntry(origin: String, cacheName: String, url: String) -> Bool {
let result = queue.sync(flags: .barrier) {
return self.storage[origin]?[cacheName]?.removeValue(forKey: url) != nil
}
persistence?.deleteCacheEntry(origin: origin, cacheName: cacheName, url: url)
return result
}
func cacheKeys(origin: String, cacheName: String) -> [String] {
queue.sync {
guard let cache = self.storage[origin]?[cacheName] else { return [] }
return Array(cache.keys)
}
}
func clear(origin: String) {
queue.async(flags: .barrier) {
self.storage.removeValue(forKey: origin)
}
persistence?.deleteOrigin(origin)
}
func clearAll() {
queue.async(flags: .barrier) {
self.storage.removeAll()
}
// Note: clearAll doesn't delete from disk - that's intentional for test isolation
}
}

View file

@ -0,0 +1,486 @@
import Foundation
import WebKit
class PWAURLProtocol: URLProtocol {
private static let handledKey = "PWAURLProtocol.handled"
static var simulateOffline = false
static var testResourceBundle: Bundle?
/// Build origin string including port if non-standard
static func buildOrigin(scheme: String, host: String, port: Int?) -> String {
var origin = "\(scheme)://\(host)"
if let port = port {
// Only add port if non-standard for the scheme
if (scheme == "http" && port != 80) || (scheme == "https" && port != 443) {
origin += ":\(port)"
}
}
return origin
}
// MARK: - Network Fetching
private var dataTask: URLSessionDataTask?
// MARK: - Registration
static func register() {
URLProtocol.registerClass(PWAURLProtocol.self)
// Private API to make WKWebView use NSURLProtocol
let className = "WKBrowsingContextController"
let selectorName = "registerSchemeForCustomProtocol:"
guard let cls = NSClassFromString(className) as? NSObject.Type else {
print("[PWAURLProtocol] Failed to find \(className)")
return
}
let selector = NSSelectorFromString(selectorName)
guard cls.responds(to: selector) else {
print("[PWAURLProtocol] \(className) doesn't respond to \(selectorName)")
return
}
cls.perform(selector, with: "https")
cls.perform(selector, with: "http")
print("[PWAURLProtocol] Registered for http and https schemes")
}
static func unregister() {
URLProtocol.unregisterClass(PWAURLProtocol.self)
let className = "WKBrowsingContextController"
let selectorName = "unregisterSchemeForCustomProtocol:"
guard let cls = NSClassFromString(className) as? NSObject.Type else { return }
let selector = NSSelectorFromString(selectorName)
guard cls.responds(to: selector) else { return }
cls.perform(selector, with: "https")
cls.perform(selector, with: "http")
}
// MARK: - URLProtocol Overrides
override class func canInit(with request: URLRequest) -> Bool {
// Already handled - prevent infinite loop
if property(forKey: handledKey, in: request) != nil {
return false
}
if let url = request.url, url.path.hasSuffix("/rpc") {
return false
}
// Skip SW context requests (marked with header)
if request.value(forHTTPHeaderField: "X-SW-Context") != nil {
return false
}
// Only intercept http/https
guard let url = request.url,
let scheme = url.scheme,
(scheme == "http" || scheme == "https"),
let host = url.host else {
return false
}
// Only intercept origins we're managing (have SW registered or being registered)
let origin = buildOrigin(scheme: scheme, host: host, port: url.port)
if origin.range(of: "ddns.net") == nil {
return false
}
return ServiceWorkerManager.shared.hasManagedOrigin(origin)
}
override class func canonicalRequest(for request: URLRequest) -> URLRequest {
return request
}
override func startLoading() {
// Mark as handled to prevent re-entry
let mutableRequest = (request as NSURLRequest).mutableCopy() as! NSMutableURLRequest
URLProtocol.setProperty(true, forKey: Self.handledKey, in: mutableRequest)
guard let url = request.url,
let scheme = url.scheme,
let host = url.host else {
client?.urlProtocol(self, didFailWithError: URLError(.badURL))
return
}
let urlPath: String
if #available(iOS 16.0, *) {
urlPath = url.path()
} else {
urlPath = url.path
}
let origin = Self.buildOrigin(scheme: scheme, host: host, port: url.port)
// Check for offline simulation (applies to all hosts including test host)
if Self.simulateOffline {
// Check cache first
if let cached = CacheStorage.shared.match(origin: origin, cacheName: nil, url: url.absoluteString) {
deliverCachedResponse(cached, for: url)
return
}
client?.urlProtocol(self, didFailWithError: URLError(.notConnectedToInternet))
return
}
// Check for active service worker first (even for test host)
// This allows SW to intercept requests before falling back to test bundle
Task {
if let registration = await ServiceWorkerManager.shared.getRegistration(for: origin, path: urlPath),
let context = await ServiceWorkerManager.shared.getContext(for: origin, path: urlPath) {
// Dispatch fetch event to SW
await self.dispatchToServiceWorker(context: context, origin: origin)
} else if host == "testpwa.local" {
// No SW, serve from test bundle for test host
await MainActor.run { self.serveTestResource(url: url) }
} else {
// No SW, fetch directly
self.fetchFromNetwork(mutableRequest as URLRequest)
}
}
}
override func stopLoading() {
dataTask?.cancel()
}
// MARK: - Network Fetching Methods
func fetchFromNetwork(_ request: URLRequest) {
dataTask = URLSession.shared.dataTask(with: request) { [weak self] data, response, error in
guard let self = self else { return }
if let error = error {
self.client?.urlProtocol(self, didFailWithError: error)
return
}
guard let httpResponse = response as? HTTPURLResponse,
var data = data else {
self.client?.urlProtocol(self, didFailWithError: URLError(.cannotParseResponse))
return
}
// Convert headers for passing to injection method
var headers = httpResponse.allHeaderFields as? [String: String] ?? [:]
// Inject shim into HTML responses
if let mimeType = httpResponse.mimeType, mimeType.contains("html") {
data = self.injectShimIntoHTML(data, headers: headers) ?? data
}
// Update content length
headers["Content-Length"] = "\(data.count)"
guard let newResponse = HTTPURLResponse(
url: self.request.url!,
statusCode: httpResponse.statusCode,
httpVersion: nil,
headerFields: headers
) else {
self.client?.urlProtocol(self, didFailWithError: URLError(.cannotParseResponse))
return
}
self.client?.urlProtocol(self, didReceive: newResponse, cacheStoragePolicy: .notAllowed)
self.client?.urlProtocol(self, didLoad: data)
self.client?.urlProtocolDidFinishLoading(self)
}
dataTask?.resume()
}
// MARK: - HTML Injection
/// Extract nonce from CSP header if present
private func extractCSPNonce(from headers: [String: String]?) -> String? {
guard let headers = headers else { return nil }
// Check both standard and report-only CSP headers
let cspHeader = headers.first { key, _ in
key.lowercased() == "content-security-policy" ||
key.lowercased() == "content-security-policy-report-only"
}?.value
guard let csp = cspHeader else { return nil }
// Parse script-src directive and extract nonce
// Format: script-src 'nonce-BASE64VALUE' ...
let pattern = "'nonce-([A-Za-z0-9+/=]+)'"
guard let regex = try? NSRegularExpression(pattern: pattern, options: []),
let match = regex.firstMatch(in: csp, options: [], range: NSRange(csp.startIndex..., in: csp)),
let nonceRange = Range(match.range(at: 1), in: csp) else {
return nil
}
return String(csp[nonceRange])
}
/// Generate a random nonce for script injection
private func generateNonce() -> String {
var bytes = [UInt8](repeating: 0, count: 16)
_ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
return Data(bytes).base64EncodedString()
}
private func injectShimIntoHTML(_ data: Data, headers: [String: String]? = nil) -> Data? {
guard var html = String(data: data, encoding: .utf8) else {
return nil
}
let mainBundle = Bundle(for: PWAWebView.self)
guard let bundle = Bundle(path: mainBundle.bundlePath + "/WebWorkerShimBundle.bundle") else {
return nil
}
guard let shimURL = bundle.url(forResource: "sw-registration-shim", withExtension: "js"),
let shimCode = try? String(contentsOf: shimURL, encoding: .utf8) else {
return nil
}
// Try to use existing nonce from CSP, or generate one and add to CSP
let nonce = extractCSPNonce(from: headers)
let nonceAttr = nonce.map { " nonce=\"\($0)\"" } ?? ""
let injection = "<script\(nonceAttr)>\(shimCode)</script>"
// Inject at start of <head> or after <!DOCTYPE>
if let headRange = html.range(of: "<head>", options: .caseInsensitive) {
html.insert(contentsOf: injection, at: headRange.upperBound)
} else if let doctypeRange = html.range(of: ">", options: [], range: html.startIndex..<html.endIndex) {
html.insert(contentsOf: injection, at: doctypeRange.upperBound)
} else {
html = injection + html
}
return html.data(using: .utf8)
}
// MARK: - Test Resource Serving
private func serveTestResource(url: URL) {
let path = url.path
let filename = (path as NSString).lastPathComponent
let ext = (filename as NSString).pathExtension
let name = (filename as NSString).deletingPathExtension
let bundle = Self.testResourceBundle ?? Bundle.main
guard let fileURL = bundle.url(forResource: name, withExtension: ext),
var data = try? Data(contentsOf: fileURL) else {
// Return 404
let response = HTTPURLResponse(url: url, statusCode: 404, httpVersion: nil, headerFields: nil)!
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: Data())
client?.urlProtocolDidFinishLoading(self)
return
}
let mimeType: String
switch ext.lowercased() {
case "html": mimeType = "text/html"
case "js": mimeType = "application/javascript"
case "json": mimeType = "application/json"
case "css": mimeType = "text/css"
default: mimeType = "application/octet-stream"
}
// Inject shim into HTML
if ext.lowercased() == "html" {
data = injectShimIntoHTML(data) ?? data
}
let headers = [
"Content-Type": mimeType,
"Content-Length": "\(data.count)"
]
let response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: headers)!
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: data)
client?.urlProtocolDidFinishLoading(self)
}
// MARK: - Cache Delivery
private func deliverCachedResponse(_ cached: CachedResponse, for url: URL) {
var headers = cached.headers
headers["Content-Length"] = "\(cached.body.count)"
guard let response = HTTPURLResponse(
url: url,
statusCode: cached.status,
httpVersion: nil,
headerFields: headers
) else {
client?.urlProtocol(self, didFailWithError: URLError(.cannotParseResponse))
return
}
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: cached.body)
client?.urlProtocolDidFinishLoading(self)
}
// MARK: - Service Worker Dispatch
private func dispatchToServiceWorker(context: ServiceWorkerContext, origin: String) async {
let requestId = UUID().uuidString
let url = request.url!.absoluteString
let method = request.httpMethod ?? "GET"
var headers: [String: String] = [:]
request.allHTTPHeaderFields?.forEach { headers[$0.key] = $0.value }
// Extract request body
var body: Data?
if let httpBody = request.httpBody {
body = httpBody
} else if let bodyStream = request.httpBodyStream {
bodyStream.open()
defer { bodyStream.close() }
// Check stream opened successfully
if bodyStream.streamStatus == .error {
print("[PWAURLProtocol] Failed to open body stream")
body = nil
} else {
var data = Data()
let bufferSize = 4096
let buffer = UnsafeMutablePointer<UInt8>.allocate(capacity: bufferSize)
defer { buffer.deallocate() }
var streamError = false
while bodyStream.hasBytesAvailable {
let bytesRead = bodyStream.read(buffer, maxLength: bufferSize)
if bytesRead > 0 {
data.append(buffer, count: bytesRead)
} else if bytesRead < 0 {
// Stream error
print("[PWAURLProtocol] Stream read error: \(bodyStream.streamError?.localizedDescription ?? "unknown")")
streamError = true
break
} else {
// EOF (bytesRead == 0)
break
}
}
body = streamError ? nil : (data.isEmpty ? nil : data)
}
}
// Map request options
var options: [String: String] = [:]
// cachePolicy -> cache
switch request.cachePolicy {
case .useProtocolCachePolicy:
options["cache"] = "default"
case .reloadIgnoringLocalCacheData:
options["cache"] = "default"
case .reloadIgnoringLocalAndRemoteCacheData:
options["cache"] = "no-store"
case .returnCacheDataElseLoad:
options["cache"] = "force-cache"
case .returnCacheDataDontLoad:
options["cache"] = "only-if-cached"
case .reloadRevalidatingCacheData:
options["cache"] = "no-cache"
@unknown default:
break
}
// httpShouldHandleCookies -> credentials
if request.httpShouldHandleCookies {
options["credentials"] = "same-origin"
} else {
options["credentials"] = "omit"
}
// Register callback
await ServiceWorkerManager.shared._registerPendingRequest(requestId, protocol: self)
// Dispatch to SW context
await MainActor.run {
print("[PWAURLProtocol] Dispatching fetch to SW for: \(url)")
context.dispatchFetchEvent(
requestId: requestId,
url: url,
method: method,
headers: headers,
body: body,
options: options.isEmpty ? nil : options
)
}
}
// Called by ServiceWorkerManager when SW responds
func handleFetchResponse(_ response: [String: Any]) {
guard let url = request.url else {
client?.urlProtocol(self, didFailWithError: URLError(.badURL))
return
}
let status = response["status"] as? Int ?? 200
var headers = response["headers"] as? [String: String] ?? [:]
let bodyBase64 = response["bodyBase64"] as? String ?? ""
var body = Data(base64Encoded: bodyBase64) ?? Data()
// Inject shim into HTML responses
let contentType = headers.first { $0.key.lowercased() == "content-type" }?.value.lowercased() ?? ""
if contentType.contains("html") {
body = injectShimIntoHTML(body, headers: headers) ?? body
}
headers["Content-Length"] = "\(body.count)"
guard let httpResponse = HTTPURLResponse(
url: url,
statusCode: status,
httpVersion: nil,
headerFields: headers
) else {
client?.urlProtocol(self, didFailWithError: URLError(.cannotParseResponse))
return
}
client?.urlProtocol(self, didReceive: httpResponse, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: body)
client?.urlProtocolDidFinishLoading(self)
}
// Called when SW passes through (doesn't handle)
func handlePassthrough() {
guard let url = request.url else {
client?.urlProtocol(self, didFailWithError: URLError(.badURL))
return
}
let scheme = url.scheme ?? "https"
let host = url.host ?? ""
let origin = Self.buildOrigin(scheme: scheme, host: host, port: url.port)
print("[PWAURLProtocol] SW passthrough for: \(url.absoluteString)")
// Check cache before network
if let cached = CacheStorage.shared.match(origin: origin, cacheName: nil, url: url.absoluteString) {
print("[PWAURLProtocol] Passthrough cache HIT")
deliverCachedResponse(cached, for: url)
return
}
// For test host, serve from test bundle instead of network
if host == "testpwa.local" {
print("[PWAURLProtocol] Passthrough serving from test bundle")
serveTestResource(url: url)
return
}
print("[PWAURLProtocol] Passthrough cache MISS, fetching from network")
// Mark request and fetch
let mutableRequest = (request as NSURLRequest).mutableCopy() as! NSMutableURLRequest
URLProtocol.setProperty(true, forKey: Self.handledKey, in: mutableRequest)
fetchFromNetwork(mutableRequest as URLRequest)
}
}

View file

@ -0,0 +1,365 @@
import WebKit
let registeredPWAProtocol: Bool = {
PWAURLProtocol.register()
// Initialize persistence
let persistence = CachePersistence.shared
CacheStorage.shared.persistence = persistence
// Load persisted data
CacheStorage.shared.loadFromDisk()
Task {
await ServiceWorkerManager.shared.setPersistence(persistence)
await ServiceWorkerManager.shared.loadFromDisk()
}
return true
}()
open class PWAWebView: WKWebView {
/// Creates a PWAWebView with message handlers configured.
override public init(frame: CGRect, configuration: WKWebViewConfiguration) {
let _ = registeredPWAProtocol
// Add message handler for SW registration and cache operations
let messageHandler = PWAMessageHandler()
configuration.userContentController.add(messageHandler, name: "swBridge")
super.init(frame: frame, configuration: configuration)
}
required public init?(coder: NSCoder) {
fatalError("PWAWebView does not support initialization from storyboard.")
}
deinit {
self.configuration.userContentController.removeScriptMessageHandler(forName: "swBridge")
}
public func loadPWA(url: URL) {
// Pre-register this origin so PWAURLProtocol knows to intercept it
if let scheme = url.scheme, let host = url.host {
let origin = PWAURLProtocol.buildOrigin(scheme: scheme, host: host, port: url.port)
Task {
await ServiceWorkerManager.shared.addManagedOrigin(origin)
}
}
// Load the real URL directly - PWAURLProtocol will intercept it
var request = URLRequest(url: url)
request.cachePolicy = .reloadIgnoringLocalCacheData
load(request)
}
}
private class PWAMessageHandler: NSObject, WKScriptMessageHandler {
/// Extract the actual origin from the page URL for validation
private func getPageOrigin(from message: WKScriptMessage) -> String? {
guard let pageURL = message.frameInfo.request.url,
let host = pageURL.host,
let scheme = pageURL.scheme else { return nil }
var origin = "\(scheme)://\(host)"
if let port = pageURL.port, port != 80 && port != 443 {
origin += ":\(port)"
}
return origin
}
/// Validate that the claimed origin matches the page's actual origin
private func validateOrigin(_ claimedOrigin: String, message: WKScriptMessage) -> Bool {
guard let pageOrigin = getPageOrigin(from: message) else { return false }
return claimedOrigin == pageOrigin
}
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
guard let body = message.body as? [String: Any],
let type = body["type"] as? String else { return }
switch type {
case "register":
handleRegister(body: body, message: message)
case "unregister":
handleUnregister(body: body, message: message)
case "cacheOpen":
if let claimedOrigin = body["origin"] as? String,
let name = body["cacheName"] as? String,
validateOrigin(claimedOrigin, message: message) {
CacheStorage.shared.open(origin: claimedOrigin, name: name)
}
case "cachePut":
if let claimedOrigin = body["origin"] as? String,
let cacheName = body["cacheName"] as? String,
let url = body["url"] as? String,
let responseDict = body["response"] as? [String: Any],
validateOrigin(claimedOrigin, message: message) {
let response = CachedResponse(
url: url,
status: responseDict["status"] as? Int ?? 200,
statusText: responseDict["statusText"] as? String ?? "OK",
headers: responseDict["headers"] as? [String: String] ?? [:],
body: Data(base64Encoded: responseDict["bodyBase64"] as? String ?? "") ?? Data()
)
CacheStorage.shared.put(origin: claimedOrigin, cacheName: cacheName, url: url, response: response)
}
case "cacheMatch":
handleCacheMatch(body: body, message: message)
case "cacheEntryDelete":
handleCacheEntryDelete(body: body, message: message)
case "cacheKeys":
handleCacheKeys(body: body, message: message)
case "cacheStorageDelete":
handleCacheStorageDelete(body: body, message: message)
case "cachesKeys":
handleCachesKeys(body: body, message: message)
default:
break
}
}
private func handleRegister(body: [String: Any], message: WKScriptMessage) {
guard let scriptURLString = body["scriptURL"] as? String,
let scriptURL = URL(string: scriptURLString),
let scope = body["scope"] as? String else { return }
let callbackId = body["callbackId"] as? String
// Get origin from the page URL (now a real https:// URL)
guard let pageURL = message.frameInfo.request.url,
let host = pageURL.host,
let scheme = pageURL.scheme else {
if let callbackId = callbackId {
message.webView?.evaluateJavaScript(
"window.resolveRegistrationCallback('\(callbackId)', false, 'Invalid page URL');",
completionHandler: nil
)
}
return
}
// Include port in origin
var origin = "\(scheme)://\(host)"
if let port = pageURL.port, port != 80 && port != 443 {
origin += ":\(port)"
}
// Register the service worker
Task {
let registration = await ServiceWorkerManager.shared.register(
scriptURL: scriptURL,
scope: scope,
origin: origin
)
// Notify JS that registration started successfully
if let callbackId = callbackId {
await MainActor.run {
message.webView?.evaluateJavaScript(
"window.resolveRegistrationCallback('\(callbackId)', true, null);",
completionHandler: nil
)
}
}
// Fetch the SW script
await self.fetchAndLoadServiceWorker(
scriptURL: scriptURL,
registration: registration,
origin: origin,
webView: message.webView,
scope: scope
)
}
}
private func fetchAndLoadServiceWorker(scriptURL: URL, registration: ServiceWorkerRegistration, origin: String, webView: WKWebView?, scope: String) async {
// Handle test host
if scriptURL.host == "testpwa.local" {
let path = scriptURL.path
let filename = (path as NSString).lastPathComponent
let ext = (filename as NSString).pathExtension
let name = (filename as NSString).deletingPathExtension
let bundle = PWAURLProtocol.testResourceBundle ?? Bundle(for: PWAMessageHandler.self)
if let fileURL = bundle.url(forResource: name, withExtension: ext),
let script = try? String(contentsOf: fileURL, encoding: .utf8) {
await loadServiceWorkerScript(script, registration: registration, origin: origin, webView: webView, scope: scope)
} else {
print("[PWAMessageHandler] Failed to load test SW script: \(filename)")
}
return
}
// Fetch from network
do {
// Mark as SW context request to avoid interception
var request = URLRequest(url: scriptURL)
request.setValue("true", forHTTPHeaderField: "X-SW-Context")
let (data, _) = try await URLSession.shared.data(for: request)
guard let script = String(data: data, encoding: .utf8) else {
print("[PWAMessageHandler] Failed to decode SW script")
return
}
await loadServiceWorkerScript(script, registration: registration, origin: origin, webView: webView, scope: scope)
} catch {
print("[PWAMessageHandler] Failed to fetch SW script: \(error)")
}
}
private func loadServiceWorkerScript(_ script: String, registration: ServiceWorkerRegistration, origin: String, webView: WKWebView?, scope: String) async {
await registration.setScriptContent(script)
// Persist the script
CachePersistence.shared.saveScript(script, for: origin)
await MainActor.run {
// Create context if needed
Task {
var context = await ServiceWorkerManager.shared.getContext(for: origin, scope: scope)
if context == nil {
context = ServiceWorkerContext(registration: registration, origin: origin, pageWebView: webView, scope: scope)
await ServiceWorkerManager.shared.setContext(context!, for: origin, scope: scope)
}
context?.loadServiceWorkerScript(script)
}
}
}
private func handleUnregister(body: [String: Any], message: WKScriptMessage) {
guard let callbackId = body["callbackId"] as? String else { return }
// Get origin from the page URL
guard let pageURL = message.frameInfo.request.url,
let host = pageURL.host,
let scheme = pageURL.scheme else {
message.webView?.evaluateJavaScript(
"window.resolveRegistrationCallback('\(callbackId)', false, 'Invalid page URL');",
completionHandler: nil
)
return
}
// Include port in origin
var origin = "\(scheme)://\(host)"
if let port = pageURL.port, port != 80 && port != 443 {
origin += ":\(port)"
}
Task {
let success = await ServiceWorkerManager.shared.unregister(origin: origin)
await ServiceWorkerManager.shared.removeContext(for: origin)
await ServiceWorkerManager.shared.removeManagedOrigin(origin)
await MainActor.run {
message.webView?.evaluateJavaScript(
"window.resolveRegistrationCallback('\(callbackId)', \(success), null);",
completionHandler: nil
)
}
}
}
private func handleCacheMatch(body: [String: Any], message: WKScriptMessage) {
guard let callbackId = body["callbackId"] as? String,
let claimedOrigin = body["origin"] as? String,
let url = body["url"] as? String,
validateOrigin(claimedOrigin, message: message) else {
// Return null for invalid/missing origin
if let callbackId = body["callbackId"] as? String {
message.webView?.evaluateJavaScript("window.resolveCacheCallback('\(callbackId)', null);", completionHandler: nil)
}
return
}
let cacheName = body["cacheName"] as? String
let response = CacheStorage.shared.match(origin: claimedOrigin, cacheName: cacheName, url: url)
let responseJS: String
if let r = response {
let dict = r.toDictionary()
let data = try? JSONSerialization.data(withJSONObject: dict, options: [])
responseJS = data.flatMap { String(data: $0, encoding: .utf8) } ?? "null"
} else {
responseJS = "null"
}
message.webView?.evaluateJavaScript("window.resolveCacheCallback('\(callbackId)', \(responseJS));", completionHandler: nil)
}
private func handleCacheEntryDelete(body: [String: Any], message: WKScriptMessage) {
guard let callbackId = body["callbackId"] as? String,
let claimedOrigin = body["origin"] as? String,
let cacheName = body["cacheName"] as? String,
let url = body["url"] as? String,
validateOrigin(claimedOrigin, message: message) else {
if let callbackId = body["callbackId"] as? String {
message.webView?.evaluateJavaScript("window.resolveCacheCallback('\(callbackId)', false);", completionHandler: nil)
}
return
}
let deleted = CacheStorage.shared.deleteEntry(origin: claimedOrigin, cacheName: cacheName, url: url)
message.webView?.evaluateJavaScript("window.resolveCacheCallback('\(callbackId)', \(deleted));", completionHandler: nil)
}
private func handleCacheKeys(body: [String: Any], message: WKScriptMessage) {
guard let callbackId = body["callbackId"] as? String,
let claimedOrigin = body["origin"] as? String,
let cacheName = body["cacheName"] as? String,
validateOrigin(claimedOrigin, message: message) else {
if let callbackId = body["callbackId"] as? String {
message.webView?.evaluateJavaScript("window.resolveCacheCallback('\(callbackId)', []);", completionHandler: nil)
}
return
}
let urls = CacheStorage.shared.cacheKeys(origin: claimedOrigin, cacheName: cacheName)
let data = try? JSONSerialization.data(withJSONObject: urls, options: [])
let urlsJS = data.flatMap { String(data: $0, encoding: .utf8) } ?? "[]"
message.webView?.evaluateJavaScript("window.resolveCacheCallback('\(callbackId)', \(urlsJS));", completionHandler: nil)
}
private func handleCacheStorageDelete(body: [String: Any], message: WKScriptMessage) {
guard let callbackId = body["callbackId"] as? String,
let claimedOrigin = body["origin"] as? String,
let name = body["cacheName"] as? String,
validateOrigin(claimedOrigin, message: message) else {
if let callbackId = body["callbackId"] as? String {
message.webView?.evaluateJavaScript("window.resolveCacheCallback('\(callbackId)', false);", completionHandler: nil)
}
return
}
let deleted = CacheStorage.shared.delete(origin: claimedOrigin, name: name)
message.webView?.evaluateJavaScript("window.resolveCacheCallback('\(callbackId)', \(deleted));", completionHandler: nil)
}
private func handleCachesKeys(body: [String: Any], message: WKScriptMessage) {
guard let callbackId = body["callbackId"] as? String,
let claimedOrigin = body["origin"] as? String,
validateOrigin(claimedOrigin, message: message) else {
if let callbackId = body["callbackId"] as? String {
message.webView?.evaluateJavaScript("window.resolveCacheCallback('\(callbackId)', []);", completionHandler: nil)
}
return
}
let names = CacheStorage.shared.keys(origin: claimedOrigin)
let data = try? JSONSerialization.data(withJSONObject: names, options: [])
let namesJS = data.flatMap { String(data: $0, encoding: .utf8) } ?? "[]"
message.webView?.evaluateJavaScript("window.resolveCacheCallback('\(callbackId)', \(namesJS));", completionHandler: nil)
}
}

View file

@ -0,0 +1,529 @@
import WebKit
protocol ServiceWorkerContextDelegate: AnyObject {
func serviceWorkerContext(_ context: ServiceWorkerContext, didReceiveFetchResponse response: [String: Any], forRequestId requestId: String)
func serviceWorkerContext(_ context: ServiceWorkerContext, requestPassthroughForRequestId requestId: String)
}
class ServiceWorkerContext: NSObject {
private var webView: WKWebView!
private let registration: ServiceWorkerRegistration
private let origin: String // Cached for synchronous access
private weak var pageWebView: WKWebView? // Page WebView for state updates
private let scope: String
weak var delegate: ServiceWorkerContextDelegate?
private var isReady = false
private var pendingScript: String?
private var loadedScriptHash: Int? // Track loaded script to prevent duplicate execution
private var pendingInstallCallback: ((Bool) -> Void)?
private var pendingActivateCallback: ((Bool) -> Void)?
init(registration: ServiceWorkerRegistration, origin: String, pageWebView: WKWebView? = nil, scope: String = "/") {
self.registration = registration
self.origin = origin
self.pageWebView = pageWebView
self.scope = scope
super.init()
setupWebView()
}
/// Notify the page WebView of SW state changes
private func notifyStateChange(_ state: String) {
let escapedScope = scope.replacingOccurrences(of: "'", with: "\\'")
let js = "if (window.updateSWState) { window.updateSWState('\(escapedScope)', '\(state)'); }"
pageWebView?.evaluateJavaScript(js, completionHandler: nil)
}
deinit {
webView?.configuration.userContentController.removeScriptMessageHandler(forName: "swBridge")
}
private func setupWebView() {
let config = WKWebViewConfiguration()
config.userContentController = WKUserContentController()
// Add message handler
config.userContentController.add(self, name: "swBridge")
// Inject context shim
let mainBundle = Bundle(for: PWAWebView.self)
if let bundle = Bundle(path: mainBundle.bundlePath + "/WebWorkerShimBundle.bundle") {
if let shimURL = bundle.url(forResource: "sw-context-shim", withExtension: "js"),
let shimCode = try? String(contentsOf: shimURL, encoding: .utf8) {
let script = WKUserScript(source: shimCode, injectionTime: .atDocumentStart, forMainFrameOnly: true)
config.userContentController.addUserScript(script)
}
}
webView = WKWebView(frame: .zero, configuration: config)
webView.loadHTMLString("<!DOCTYPE html><html><head></head><body></body></html>", baseURL: nil)
}
func loadServiceWorkerScript(_ script: String) {
let scriptHash = script.hashValue
// Skip if same script is already loaded (prevents duplicate variable errors on page reload)
if let existingHash = loadedScriptHash, existingHash == scriptHash {
// Script already loaded - notify page of current state
Task {
let state = await registration.state
if state == .activated {
await MainActor.run { self.notifyStateChange("activated") }
}
}
return
}
// If a different script, we need to reset the WebView (SW update scenario)
if loadedScriptHash != nil {
resetWebView()
}
loadedScriptHash = scriptHash
if isReady {
executeScript(script)
} else {
pendingScript = script
}
}
private func resetWebView() {
webView?.configuration.userContentController.removeScriptMessageHandler(forName: "swBridge")
isReady = false
pendingScript = nil
setupWebView()
}
private func escapeForJS(_ string: String) -> String {
// Wrap string in array since JSONSerialization requires array/dict as top-level
guard let data = try? JSONSerialization.data(withJSONObject: [string], options: []),
let jsonArray = String(data: data, encoding: .utf8) else {
return "\"\""
}
// Remove the array brackets: ["string"] -> "string"
let trimmed = jsonArray.dropFirst().dropLast()
return String(trimmed)
}
private func executeScript(_ script: String) {
// Notify page that SW is installing
notifyStateChange("installing")
Task {
let scriptURL = await registration.scriptURL
let realScriptURL = scriptURL.absoluteString // Already a real https:// URL
await MainActor.run {
let escapedOrigin = self.escapeForJS(self.origin)
let escapedScriptURL = self.escapeForJS(realScriptURL)
let setOrigin = "window.setOrigin(\(escapedOrigin), \(escapedScriptURL));"
self.webView.evaluateJavaScript(setOrigin) { [weak self] _, _ in
self?.webView.evaluateJavaScript(script) { [weak self] _, error in
if let error = error {
print("SW script error: \(error)")
return
}
self?.dispatchInstallEvent()
}
}
}
}
}
private func dispatchInstallEvent() {
// The JS function returns a Promise that resolves after all waitUntil promises complete.
// We use a callback-based approach to properly wait for the Promise.
let callbackId = "install_\(UUID().uuidString)"
let js = """
(function() {
window.dispatchInstallEvent().then(function(success) {
webkit.messageHandlers.swBridge.postMessage({
type: 'installComplete',
callbackId: '\(callbackId)',
success: success
});
}).catch(function(err) {
webkit.messageHandlers.swBridge.postMessage({
type: 'installComplete',
callbackId: '\(callbackId)',
success: false,
error: err.message || String(err)
});
});
})();
"""
pendingInstallCallback = { [weak self] success in
guard let self = self else { return }
if success {
Task {
await ServiceWorkerManager.shared.updateState(self.registration, to: .installed)
await MainActor.run {
self.notifyStateChange("installed")
self.dispatchActivateEvent()
}
}
} else {
Task { await ServiceWorkerManager.shared.updateState(self.registration, to: .redundant) }
}
}
webView.evaluateJavaScript(js) { [weak self] _, error in
if let error = error {
print("SW install event error: \(error)")
self?.pendingInstallCallback?(false)
self?.pendingInstallCallback = nil
}
}
}
private func dispatchActivateEvent() {
// The JS function returns a Promise that resolves after all waitUntil promises complete.
// We use a callback-based approach to properly wait for the Promise.
let callbackId = "activate_\(UUID().uuidString)"
let js = """
(function() {
window.dispatchActivateEvent().then(function(success) {
webkit.messageHandlers.swBridge.postMessage({
type: 'activateComplete',
callbackId: '\(callbackId)',
success: success
});
}).catch(function(err) {
webkit.messageHandlers.swBridge.postMessage({
type: 'activateComplete',
callbackId: '\(callbackId)',
success: false,
error: err.message || String(err)
});
});
})();
"""
pendingActivateCallback = { [weak self] success in
guard let self = self else { return }
if success {
Task {
await ServiceWorkerManager.shared.updateState(self.registration, to: .activated)
await MainActor.run { self.notifyStateChange("activated") }
}
} else {
Task { await ServiceWorkerManager.shared.updateState(self.registration, to: .redundant) }
}
}
webView.evaluateJavaScript(js) { [weak self] _, error in
if let error = error {
print("SW activate event error: \(error)")
self?.pendingActivateCallback?(false)
self?.pendingActivateCallback = nil
}
}
}
func dispatchFetchEvent(requestId: String, url: String, method: String, headers: [String: String], body: Data?, options: [String: String]?) {
let escapedRequestId = escapeForJS(requestId)
let escapedUrl = escapeForJS(url)
let escapedMethod = escapeForJS(method)
let headersJSON = (try? JSONSerialization.data(withJSONObject: headers, options: []))
.flatMap { String(data: $0, encoding: .utf8) } ?? "{}"
// Encode body as base64, or null if empty
let bodyBase64: String
if let body = body, !body.isEmpty {
bodyBase64 = "\"\(body.base64EncodedString())\""
} else {
bodyBase64 = "null"
}
// Encode options as JSON, or null if empty
let optionsJSON: String
if let options = options, !options.isEmpty {
optionsJSON = (try? JSONSerialization.data(withJSONObject: options, options: []))
.flatMap { String(data: $0, encoding: .utf8) } ?? "null"
} else {
optionsJSON = "null"
}
let js = "window.dispatchFetchEvent(\(escapedRequestId), \(escapedUrl), \(escapedMethod), \(headersJSON), \(bodyBase64), \(optionsJSON));"
webView.evaluateJavaScript(js, completionHandler: nil)
}
}
// MARK: - SW Fetch Handling
extension ServiceWorkerContext {
func handleSWFetch(requestId: String, urlString: String, origin: String, method: String, headers: [String: String], body: Data?) {
// Check for test host - serve from test bundle
if let url = URL(string: urlString), url.host == "testpwa.local" {
serveTestResource(requestId: requestId, url: url, method: method, body: body)
return
}
// Fetch from network
guard let url = URL(string: urlString) else {
sendSWFetchError(requestId: requestId, error: "Invalid URL")
return
}
var request = URLRequest(url: url)
request.httpMethod = method
for (key, value) in headers {
request.setValue(value, forHTTPHeaderField: key)
}
// Mark as SW context request to prevent re-interception by PWAURLProtocol
request.setValue("true", forHTTPHeaderField: "X-SW-Context")
// Set request body if provided (for POST, PUT, etc.)
if let body = body, !body.isEmpty {
request.httpBody = body
}
URLSession.shared.dataTask(with: request) { [weak self] data, response, error in
DispatchQueue.main.async {
if let error = error {
self?.sendSWFetchError(requestId: requestId, error: error.localizedDescription)
return
}
guard let httpResponse = response as? HTTPURLResponse,
let data = data else {
self?.sendSWFetchError(requestId: requestId, error: "Invalid response")
return
}
var responseHeaders: [String: String] = [:]
httpResponse.allHeaderFields.forEach { key, value in
if let keyStr = key as? String, let valueStr = value as? String {
responseHeaders[keyStr] = valueStr
}
}
let responseDict: [String: Any] = [
"status": httpResponse.statusCode,
"statusText": HTTPURLResponse.localizedString(forStatusCode: httpResponse.statusCode),
"headers": responseHeaders,
"bodyBase64": data.base64EncodedString()
]
self?.sendSWFetchResponse(requestId: requestId, response: responseDict)
}
}.resume()
}
private func serveTestResource(requestId: String, url: URL, method: String = "GET", body: Data? = nil) {
let path = url.path
// Special endpoint: /echo-body - echoes the request body back as JSON
if path == "/echo-body" || path.hasSuffix("/echo-body") {
let bodyString = body.flatMap { String(data: $0, encoding: .utf8) } ?? ""
let responseJSON: [String: Any] = [
"method": method,
"bodyReceived": bodyString,
"bodyLength": body?.count ?? 0
]
guard let jsonData = try? JSONSerialization.data(withJSONObject: responseJSON, options: []) else {
sendSWFetchError(requestId: requestId, error: "Failed to serialize echo response")
return
}
let responseDict: [String: Any] = [
"status": 200,
"statusText": "OK",
"headers": ["Content-Type": "application/json"],
"bodyBase64": jsonData.base64EncodedString()
]
sendSWFetchResponse(requestId: requestId, response: responseDict)
return
}
let filename = (path as NSString).lastPathComponent
let ext = (filename as NSString).pathExtension
let name = (filename as NSString).deletingPathExtension
let bundle = PWAURLProtocol.testResourceBundle ?? Bundle.main
guard let fileURL = bundle.url(forResource: name, withExtension: ext),
let data = try? Data(contentsOf: fileURL) else {
sendSWFetchError(requestId: requestId, error: "Resource not found: \(filename)")
return
}
let mimeType: String
switch ext.lowercased() {
case "html": mimeType = "text/html"
case "js": mimeType = "application/javascript"
case "json": mimeType = "application/json"
case "css": mimeType = "text/css"
default: mimeType = "application/octet-stream"
}
let responseDict: [String: Any] = [
"status": 200,
"statusText": "OK",
"headers": ["Content-Type": mimeType],
"bodyBase64": data.base64EncodedString()
]
sendSWFetchResponse(requestId: requestId, response: responseDict)
}
private func sendSWFetchResponse(requestId: String, response: [String: Any]) {
guard let data = try? JSONSerialization.data(withJSONObject: response, options: []),
let responseJS = String(data: data, encoding: .utf8) else {
sendSWFetchError(requestId: requestId, error: "Failed to serialize response")
return
}
let js = "window.resolveSWFetchCallback('\(requestId)', \(responseJS), null);"
webView.evaluateJavaScript(js, completionHandler: nil)
}
private func sendSWFetchError(requestId: String, error: String) {
let escapedError = error.replacingOccurrences(of: "'", with: "\\'")
let js = "window.resolveSWFetchCallback('\(requestId)', null, '\(escapedError)');"
webView.evaluateJavaScript(js, completionHandler: nil)
}
}
extension ServiceWorkerContext: WKScriptMessageHandler {
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
guard let body = message.body as? [String: Any],
let type = body["type"] as? String else { return }
switch type {
case "contextReady":
isReady = true
if let script = pendingScript {
pendingScript = nil
executeScript(script)
}
case "installComplete":
let success = body["success"] as? Bool ?? false
pendingInstallCallback?(success)
pendingInstallCallback = nil
case "activateComplete":
let success = body["success"] as? Bool ?? false
pendingActivateCallback?(success)
pendingActivateCallback = nil
case "fetchResponse":
guard let requestId = body["requestId"] as? String else { return }
// Route to PWAURLProtocol if this is a protocol request
Task {
if let urlProtocol = await ServiceWorkerManager.shared.getPendingRequest(requestId) {
await MainActor.run {
if body["passthrough"] as? Bool == true {
urlProtocol.handlePassthrough()
} else if let response = body["response"] as? [String: Any] {
urlProtocol.handleFetchResponse(response)
} else if body["error"] != nil {
urlProtocol.handlePassthrough()
}
}
} else {
// Fallback to delegate (for backwards compatibility during migration)
await MainActor.run {
if body["passthrough"] as? Bool == true {
self.delegate?.serviceWorkerContext(self, requestPassthroughForRequestId: requestId)
} else if let response = body["response"] as? [String: Any] {
self.delegate?.serviceWorkerContext(self, didReceiveFetchResponse: response, forRequestId: requestId)
} else if body["error"] != nil {
self.delegate?.serviceWorkerContext(self, requestPassthroughForRequestId: requestId)
}
}
}
}
case "cacheOpen":
if let origin = body["origin"] as? String,
let name = body["cacheName"] as? String {
CacheStorage.shared.open(origin: origin, name: name)
}
case "cachePut":
if let origin = body["origin"] as? String,
let cacheName = body["cacheName"] as? String,
let url = body["url"] as? String,
let responseDict = body["response"] as? [String: Any] {
let response = CachedResponse(
url: url,
status: responseDict["status"] as? Int ?? 200,
statusText: responseDict["statusText"] as? String ?? "OK",
headers: responseDict["headers"] as? [String: String] ?? [:],
body: Data(base64Encoded: responseDict["bodyBase64"] as? String ?? "") ?? Data()
)
CacheStorage.shared.put(origin: origin, cacheName: cacheName, url: url, response: response)
}
case "cacheMatch":
if let callbackId = body["callbackId"] as? String,
let origin = body["origin"] as? String,
let url = body["url"] as? String {
let cacheName = body["cacheName"] as? String
let response = CacheStorage.shared.match(origin: origin, cacheName: cacheName, url: url)
let responseJS: String
if let r = response {
let dict = r.toDictionary()
let data = try? JSONSerialization.data(withJSONObject: dict, options: [])
responseJS = data.flatMap { String(data: $0, encoding: .utf8) } ?? "null"
} else {
responseJS = "null"
}
webView.evaluateJavaScript("window.resolveCacheCallback('\(callbackId)', \(responseJS));", completionHandler: nil)
}
case "cacheEntryDelete":
if let callbackId = body["callbackId"] as? String,
let origin = body["origin"] as? String,
let cacheName = body["cacheName"] as? String,
let url = body["url"] as? String {
let deleted = CacheStorage.shared.deleteEntry(origin: origin, cacheName: cacheName, url: url)
webView.evaluateJavaScript("window.resolveCacheCallback('\(callbackId)', \(deleted));", completionHandler: nil)
}
case "cachesKeys":
if let callbackId = body["callbackId"] as? String,
let origin = body["origin"] as? String {
let names = CacheStorage.shared.keys(origin: origin)
let data = try? JSONSerialization.data(withJSONObject: names, options: [])
let namesJS = data.flatMap { String(data: $0, encoding: .utf8) } ?? "[]"
webView.evaluateJavaScript("window.resolveCacheCallback('\(callbackId)', \(namesJS));", completionHandler: nil)
}
case "cacheStorageDelete":
// Handle both callback-based and fire-and-forget versions
if let callbackId = body["callbackId"] as? String,
let origin = body["origin"] as? String,
let name = body["cacheName"] as? String {
let deleted = CacheStorage.shared.delete(origin: origin, name: name)
webView.evaluateJavaScript("window.resolveCacheCallback('\(callbackId)', \(deleted));", completionHandler: nil)
} else if let origin = body["origin"] as? String,
let name = body["cacheName"] as? String {
_ = CacheStorage.shared.delete(origin: origin, name: name)
}
case "swFetch":
if let requestId = body["requestId"] as? String,
let urlString = body["url"] as? String,
let origin = body["origin"] as? String {
let method = body["method"] as? String ?? "GET"
let headers = body["headers"] as? [String: String] ?? [:]
// Decode body from base64 if provided
var requestBody: Data?
if let bodyBase64 = body["bodyBase64"] as? String {
requestBody = Data(base64Encoded: bodyBase64)
}
handleSWFetch(requestId: requestId, urlString: urlString, origin: origin, method: method, headers: headers, body: requestBody)
}
default:
break
}
}
}

View file

@ -0,0 +1,333 @@
import Foundation
enum ServiceWorkerState: String, Codable, Sendable {
case installing
case installed
case activating
case activated
case redundant
}
/// Thread-safe service worker registration using actor isolation
actor ServiceWorkerRegistration {
let id: UUID
let scriptURL: URL
let scope: String
let origin: String
var state: ServiceWorkerState = .installing
var scriptContent: String?
init(scriptURL: URL, scope: String, origin: String) {
self.id = UUID()
self.scriptURL = scriptURL
self.scope = scope
self.origin = origin
}
func matchesScope(path: String) -> Bool {
// Direct match
if path.hasPrefix(scope) {
return true
}
// If scope ends with / and path doesn't, try adding / to path
// e.g., scope="/app/" should match path="/app"
if scope.hasSuffix("/") && !path.hasSuffix("/") {
return (path + "/").hasPrefix(scope)
}
return false
}
func setState(_ newState: ServiceWorkerState) {
state = newState
}
func setScriptContent(_ content: String?) {
scriptContent = content
}
// Snapshot for persistence (captures current state synchronously within actor)
func snapshot() -> RegistrationSnapshot {
RegistrationSnapshot(
id: id,
scriptURL: scriptURL,
scope: scope,
origin: origin,
state: state
)
}
// Restore from snapshot
init(from snapshot: RegistrationSnapshot) {
self.id = snapshot.id
self.scriptURL = snapshot.scriptURL
self.scope = snapshot.scope
self.origin = snapshot.origin
self.state = snapshot.state
self.scriptContent = nil
}
}
/// Codable snapshot for persistence
struct RegistrationSnapshot: Codable, Sendable {
let id: UUID
let scriptURL: URL
let scope: String
let origin: String
let state: ServiceWorkerState
}
/// Thread-safe set for tracking managed origins (accessed from URLProtocol.canInit)
private class ManagedOriginsStorage {
private var origins: Set<String> = []
private let queue = DispatchQueue(label: "com.workershim.managedOrigins", attributes: .concurrent)
func contains(_ origin: String) -> Bool {
queue.sync { origins.contains(origin) }
}
func insert(_ origin: String) {
queue.async(flags: .barrier) { self.origins.insert(origin) }
}
func remove(_ origin: String) {
queue.async(flags: .barrier) { self.origins.remove(origin) }
}
func removeAll() {
queue.async(flags: .barrier) { self.origins.removeAll() }
}
}
actor ServiceWorkerManager {
static let shared = ServiceWorkerManager()
// Thread-safe storage for managed origins (accessible outside actor)
private static let _managedOriginsStorage = ManagedOriginsStorage()
// Changed from [origin: registration] to [origin: [scope: registration]]
// to support multiple scopes per origin
private var registrations: [String: [String: ServiceWorkerRegistration]] = [:]
private var contexts: [String: [String: ServiceWorkerContext]] = [:] // [origin: [scope: context]]
private var pendingRequests: [String: PWAURLProtocol] = [:]
// Persistence layer (optional, nil during some tests)
private var persistence: CachePersistence?
private init() {}
func setPersistence(_ persistence: CachePersistence?) {
self.persistence = persistence
}
// MARK: - Persistence Integration
func loadFromDisk() async {
guard let persistence = persistence else { return }
let loaded = persistence.loadAllRegistrations()
for snapshot in loaded {
if snapshot.state == .activated {
let registration = ServiceWorkerRegistration(from: snapshot)
if registrations[snapshot.origin] == nil {
registrations[snapshot.origin] = [:]
}
registrations[snapshot.origin]?[snapshot.scope] = registration
// Add to managed origins so URLProtocol knows to intercept
addManagedOrigin(snapshot.origin)
}
}
}
func register(scriptURL: URL, scope: String, origin: String) async -> ServiceWorkerRegistration {
// Add to managed origins (thread-safe)
addManagedOrigin(origin)
// Initialize origin dict if needed
if registrations[origin] == nil {
registrations[origin] = [:]
}
// If registration already exists for this origin+scope, return it
if let existing = registrations[origin]?[scope] {
let existingScript = await existing.scriptURL
if existingScript == scriptURL {
return existing
}
// Different script - this is an update, let the existing flow continue
}
let registration = ServiceWorkerRegistration(scriptURL: scriptURL, scope: scope, origin: origin)
registrations[origin]?[scope] = registration
if let persistence = persistence {
let snapshot = await registration.snapshot()
persistence.saveRegistration(snapshot)
}
return registration
}
func getRegistration(for origin: String, path: String) async -> ServiceWorkerRegistration? {
guard let scopeRegs = registrations[origin] else {
print("[ServiceWorkerManager] No registration for origin '\(origin)'. Known origins: \(Array(registrations.keys))")
return nil
}
// Find the most specific matching scope (longest scope that matches)
var bestMatch: ServiceWorkerRegistration?
var bestScopeLength = -1
for (scope, reg) in scopeRegs {
let matches = await reg.matchesScope(path: path)
let state = await reg.state
if matches && state == .activated && scope.count > bestScopeLength {
bestMatch = reg
bestScopeLength = scope.count
}
}
if bestMatch == nil {
print("[ServiceWorkerManager] No matching activated registration for path '\(path)' in origin '\(origin)'")
}
return bestMatch
}
func getRegistration(for origin: String) -> ServiceWorkerRegistration? {
// Return first registration for this origin (for backward compatibility)
return registrations[origin]?.values.first
}
func getRegistration(for origin: String, scope: String) -> ServiceWorkerRegistration? {
return registrations[origin]?[scope]
}
func getState(for origin: String) async -> ServiceWorkerState? {
// Return state of first registration (for backward compatibility)
guard let reg = registrations[origin]?.values.first else { return nil }
return await reg.state
}
func getState(for origin: String, scope: String) async -> ServiceWorkerState? {
guard let reg = registrations[origin]?[scope] else { return nil }
return await reg.state
}
func updateState(_ registration: ServiceWorkerRegistration, to state: ServiceWorkerState) async {
let origin = await registration.origin
let scope = await registration.scope
print("[ServiceWorkerManager] Updating state for origin '\(origin)', scope '\(scope)' to '\(state)'")
await registration.setState(state)
if let persistence = persistence {
let snapshot = await registration.snapshot()
persistence.saveRegistration(snapshot)
}
}
func unregister(origin: String) -> Bool {
let removed = registrations.removeValue(forKey: origin) != nil
contexts.removeValue(forKey: origin)
removeManagedOrigin(origin)
persistence?.deleteOrigin(origin)
return removed
}
func unregister(origin: String, scope: String) -> Bool {
let removed = registrations[origin]?.removeValue(forKey: scope) != nil
contexts[origin]?.removeValue(forKey: scope)
// Only delete from persistence and managed origins if no more scopes for this origin
if registrations[origin]?.isEmpty == true {
registrations.removeValue(forKey: origin)
contexts.removeValue(forKey: origin)
removeManagedOrigin(origin)
persistence?.deleteOrigin(origin)
}
return removed
}
func clearAll() {
registrations.removeAll()
clearManagedOrigins()
contexts.removeAll()
pendingRequests.removeAll()
}
// MARK: - Managed Origins
nonisolated func hasManagedOrigin(_ origin: String) -> Bool {
// Thread-safe check - uses concurrent queue for reads
return Self._managedOriginsStorage.contains(origin)
}
func addManagedOrigin(_ origin: String) {
Self._managedOriginsStorage.insert(origin)
}
func removeManagedOrigin(_ origin: String) {
Self._managedOriginsStorage.remove(origin)
}
func clearManagedOrigins() {
Self._managedOriginsStorage.removeAll()
}
// MARK: - Context Management
func setContext(_ context: ServiceWorkerContext, for origin: String, scope: String = "/") {
if contexts[origin] == nil {
contexts[origin] = [:]
}
contexts[origin]?[scope] = context
}
func getContext(for origin: String) -> ServiceWorkerContext? {
// Return first context for this origin (for backward compatibility)
return contexts[origin]?.values.first
}
func getContext(for origin: String, scope: String) -> ServiceWorkerContext? {
return contexts[origin]?[scope]
}
func getContext(for origin: String, path: String) async -> ServiceWorkerContext? {
guard let scopeContexts = contexts[origin] else { return nil }
// Find the most specific matching scope (longest scope that matches)
var bestMatch: ServiceWorkerContext?
var bestScopeLength = -1
for (scope, context) in scopeContexts {
if path.hasPrefix(scope) && scope.count > bestScopeLength {
bestMatch = context
bestScopeLength = scope.count
}
}
return bestMatch
}
func removeContext(for origin: String) {
contexts.removeValue(forKey: origin)
}
func removeContext(for origin: String, scope: String) {
contexts[origin]?.removeValue(forKey: scope)
if contexts[origin]?.isEmpty == true {
contexts.removeValue(forKey: origin)
}
}
// MARK: - Pending Requests (for URLProtocol callbacks)
nonisolated func registerPendingRequest(_ requestId: String, protocol urlProtocol: PWAURLProtocol) {
Task {
await self._registerPendingRequest(requestId, protocol: urlProtocol)
}
}
func _registerPendingRequest(_ requestId: String, protocol urlProtocol: PWAURLProtocol) {
pendingRequests[requestId] = urlProtocol
}
func getPendingRequest(_ requestId: String) -> PWAURLProtocol? {
return pendingRequests.removeValue(forKey: requestId)
}
}