LiveActivity pause button added

This commit is contained in:
Daniil Vinogradov
2024-06-30 23:24:33 +02:00
parent 435f546d69
commit 12ecbe4294
11 changed files with 334 additions and 23 deletions
+48 -17
View File
@@ -7,14 +7,13 @@
#if canImport(ActivityKit)
import ActivityKit
import AppIntents
import MarqueeText
import SwiftUI
import WidgetKit
import MarqueeText
struct ProgressWidgetLiveActivity: Widget {
static var userDefaults: UserDefaults { UserDefaults(suiteName: "group.itorrent.life-activity") ?? .standard }
static var userDefaults: UserDefaults { .itorrentGroup }
static var tintColor: UIColor {
guard let data = Self.userDefaults.data(forKey: "preferencesTintColor")
@@ -26,7 +25,7 @@ struct ProgressWidgetLiveActivity: Widget {
let config = ActivityConfiguration(for: ProgressWidgetAttributes.self) { context in
// Lock screen/banner UI goes here
if #available(iOSApplicationExtension 18, *) {
if #available(iOS 18, *) {
#if XCODE16
ProgressWidgetLiveActivityWatchSupportContent(context: context)
.tint(Color(uiColor: ProgressWidgetLiveActivity.tintColor))
@@ -37,7 +36,7 @@ struct ProgressWidgetLiveActivity: Widget {
.padding()
#endif
} else {
} else {
ProgressWidgetLiveActivityContent(context: context)
.tint(Color(uiColor: Self.tintColor))
.padding()
@@ -64,6 +63,17 @@ struct ProgressWidgetLiveActivity: Widget {
if context.state.state == .downloading {
Text(context.state.timeRemainig)
}
if #available(iOS 17.0, *),
context.state.state == .seeding
{
let intent = {
let intent = PauseTorrentIntent()
intent.torrentHash = context.attributes.hash
return intent
}()
PauseButton(intent: intent)
}
}
ProgressView(value: context.state.progress)
.progressViewStyle(.linear)
@@ -92,7 +102,6 @@ struct ProgressWidgetLiveActivity: Widget {
return config
}
}
}
#if XCODE16
@@ -124,9 +133,9 @@ struct ProgressWidgetLiveActivityWatchSupportContent: View {
Text(context.state.state.name)
Spacer()
case .downloading:
Text(String("\(context.state.downSpeed.bitrateToHumanReadable)/s ↓"))
Spacer()
Text(String("\(context.state.upSpeed.bitrateToHumanReadable)/s ↑"))
Text(String("\(context.state.downSpeed.bitrateToHumanReadable)/s ↓"))
Spacer()
Text(String("\(context.state.upSpeed.bitrateToHumanReadable)/s ↑"))
case .finished:
Text(context.state.state.name)
Spacer()
@@ -167,6 +176,17 @@ struct ProgressWidgetLiveActivityContent: View {
HStack {
Text(context.attributes.name)
Spacer()
if #available(iOS 17.0, *),
context.state.state == .seeding
{
let intent = {
let intent = PauseTorrentIntent()
intent.torrentHash = context.attributes.hash
return intent
}()
PauseButton(intent: intent)
}
}
HStack {
switch context.state.state {
@@ -203,6 +223,17 @@ struct ProgressWidgetLiveActivityContent: View {
}
}
@available(iOS 17.0, *)
struct PauseButton: View {
let intent: any LiveActivityIntent
var body: some View {
Button(intent: intent) {
Image(systemName: "pause.fill")
}
}
}
struct LeadingView: View {
@State var context: ActivityViewContext<ProgressWidgetAttributes>
@@ -240,19 +271,19 @@ struct TrailingView: View {
}
}
//#Preview("Progress",
// #Preview("Progress",
// as: .dynamicIsland(.compact),
// using: ProgressWidgetAttributes(name: "Test torrent", hash: "")
//) {
// ) {
// ProgressWidgetLiveActivity()
//} contentStates: {
// } contentStates: {
// ProgressWidgetAttributes.ContentState(progress: 0.2, downSpeed: 2000, upSpeed: 1000, timeRemainig: "Осталось САСАТБ", timeStamp: .now)
//}
// }
//#Preview("Notification", as: .content, using: ProgressWidgetAttributes(name: "Test torrent", hash: "")) {
// #Preview("Notification", as: .content, using: ProgressWidgetAttributes(name: "Test torrent", hash: "")) {
// ProgressWidgetLiveActivity()
//} contentStates: {
// } contentStates: {
// ProgressWidgetAttributes.ContentState(progress: 0.2, downSpeed: 2000, upSpeed: 1000, timeRemainig: "Осталось САСАТБ", timeStamp: .now)
// ProgressWidgetAttributes.ContentState(progress: 0.7, downSpeed: 12000000, upSpeed: 1000000, timeRemainig: "Осталось САСАТБ", timeStamp: .now)
//}
// }
#endif
+41 -1
View File
@@ -10,6 +10,9 @@
7C013DE32C28AA3C0026A11B /* sound.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 7C013DE22C28A97F0026A11B /* sound.m4a */; };
7C013DE42C2F38AA0026A11B /* LocalizationAttribute.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1352D1D2BBC2F7F00104E7B /* LocalizationAttribute.swift */; };
7C013DE52C2F41760026A11B /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = D1AA00CF2AFAC7D200B74629 /* Localizable.xcstrings */; };
7C1C08AA2C31F2F800569B45 /* PauseTorrentIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C1C08A92C31F2F000569B45 /* PauseTorrentIntent.swift */; };
7C1C08AB2C31F31900569B45 /* PauseTorrentIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C1C08A92C31F2F000569B45 /* PauseTorrentIntent.swift */; };
7C1C08AD2C31FEA400569B45 /* IntentsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C1C08AC2C31FEA000569B45 /* IntentsService.swift */; };
7C3142D02C317E6400397E82 /* MarqueeText in Frameworks */ = {isa = PBXBuildFile; productRef = 7C3142CF2C317E6400397E82 /* MarqueeText */; };
7C4CF2FA2BDE712D0078FEA1 /* UnityAdsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C4CF2F92BDE712D0078FEA1 /* UnityAdsManager.swift */; };
7C4CF2FC2BDE78F50078FEA1 /* AdView+Unity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C4CF2FB2BDE78F50078FEA1 /* AdView+Unity.swift */; };
@@ -55,6 +58,8 @@
7CB2639C2C0671320083C052 /* Publisher+UI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CB2639B2C0671320083C052 /* Publisher+UI.swift */; };
7CB2639E2C0A5B420083C052 /* BaseSafariViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CB2639D2C0A5B420083C052 /* BaseSafariViewController.swift */; };
7CB6F6CE2BD82BAB00D0813B /* FileSharingPreferencesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CB6F6CD2BD82BAB00D0813B /* FileSharingPreferencesViewModel.swift */; };
7CBDBAAD2C31EF0C008C986B /* UserDefaults+AppGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CBDBAAC2C31EF0C008C986B /* UserDefaults+AppGroup.swift */; };
7CBDBAAE2C31EF52008C986B /* UserDefaults+AppGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CBDBAAC2C31EF0C008C986B /* UserDefaults+AppGroup.swift */; };
7CC411582BD2DCF800CA8B13 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 7CC411572BD2DCF800CA8B13 /* GoogleService-Info.plist */; };
7CC4115B2BD2DE3800CA8B13 /* FirebaseAnalyticsWithoutAdIdSupport in Frameworks */ = {isa = PBXBuildFile; platformFilters = (ios, xros, ); productRef = 7CC4115A2BD2DE3800CA8B13 /* FirebaseAnalyticsWithoutAdIdSupport */; };
7CC4115D2BD2DE3800CA8B13 /* FirebaseCrashlytics in Frameworks */ = {isa = PBXBuildFile; platformFilters = (ios, xros, ); productRef = 7CC4115C2BD2DE3800CA8B13 /* FirebaseCrashlytics */; };
@@ -239,6 +244,8 @@
/* Begin PBXFileReference section */
7C013DE22C28A97F0026A11B /* sound.m4a */ = {isa = PBXFileReference; lastKnownFileType = file; path = sound.m4a; sourceTree = "<group>"; };
7C1C08A92C31F2F000569B45 /* PauseTorrentIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PauseTorrentIntent.swift; sourceTree = "<group>"; };
7C1C08AC2C31FEA000569B45 /* IntentsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntentsService.swift; sourceTree = "<group>"; };
7C4CF2F22BDD586C0078FEA1 /* UnityAds.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = UnityAds.xcframework; path = Submodules/UnityAds.xcframework; sourceTree = "<group>"; };
7C4CF2F92BDE712D0078FEA1 /* UnityAdsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnityAdsManager.swift; sourceTree = "<group>"; };
7C4CF2FB2BDE78F50078FEA1 /* AdView+Unity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AdView+Unity.swift"; sourceTree = "<group>"; };
@@ -286,6 +293,7 @@
7CB2639B2C0671320083C052 /* Publisher+UI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Publisher+UI.swift"; sourceTree = "<group>"; };
7CB2639D2C0A5B420083C052 /* BaseSafariViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseSafariViewController.swift; sourceTree = "<group>"; };
7CB6F6CD2BD82BAB00D0813B /* FileSharingPreferencesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileSharingPreferencesViewModel.swift; sourceTree = "<group>"; };
7CBDBAAC2C31EF0C008C986B /* UserDefaults+AppGroup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserDefaults+AppGroup.swift"; sourceTree = "<group>"; };
7CC411572BD2DCF800CA8B13 /* GoogleService-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = "<group>"; };
7CC411612BD319AE00CA8B13 /* AppDelegate+RemoteConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+RemoteConfig.swift"; sourceTree = "<group>"; };
7CC411632BD326C300CA8B13 /* AppDelegate+Firebase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+Firebase.swift"; sourceTree = "<group>"; };
@@ -465,6 +473,31 @@
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
7C1C08AE2C31FEF700569B45 /* IntentsService */ = {
isa = PBXGroup;
children = (
7C1C08AF2C31FEFD00569B45 /* Intents */,
7C1C08AC2C31FEA000569B45 /* IntentsService.swift */,
);
path = IntentsService;
sourceTree = "<group>";
};
7C1C08AF2C31FEFD00569B45 /* Intents */ = {
isa = PBXGroup;
children = (
7C1C08A92C31F2F000569B45 /* PauseTorrentIntent.swift */,
);
path = Intents;
sourceTree = "<group>";
};
7C3142D42C31ED4600397E82 /* LiveActivityService */ = {
isa = PBXGroup;
children = (
7CF6DA3D2C0F9DC40033D03F /* LiveActivityService.swift */,
);
path = LiveActivityService;
sourceTree = "<group>";
};
7C4CF2F82BDE711A0078FEA1 /* Ads */ = {
isa = PBXGroup;
children = (
@@ -958,6 +991,7 @@
D1AA00CA2AFA8A9200B74629 /* UserDefaultItem.swift */,
7C5FBE222BBDD1B60069E5A0 /* NSUserDefaultItem.swift */,
7CE25BA62C24A848007B2FD7 /* CircularAnimation.swift */,
7CBDBAAC2C31EF0C008C986B /* UserDefaults+AppGroup.swift */,
);
path = Utils;
sourceTree = "<group>";
@@ -1059,6 +1093,8 @@
D1A226F02AEF018500669D6D /* Services */ = {
isa = PBXGroup;
children = (
7C1C08AE2C31FEF700569B45 /* IntentsService */,
7C3142D42C31ED4600397E82 /* LiveActivityService */,
D1B99D842BEE5E4100F51514 /* Patreon */,
7C4CF2F82BDE711A0078FEA1 /* Ads */,
D1DB71892BD915F4007F9267 /* ImageCache */,
@@ -1070,7 +1106,6 @@
7C5FBE182BBC91E70069E5A0 /* NetworkMonitoringService.swift */,
D1352D2B2BBD6E0E00104E7B /* MemorySpaceManager.swift */,
D1352D3B2BBD7F8800104E7B /* TorrentMonitoringService.swift */,
7CF6DA3D2C0F9DC40033D03F /* LiveActivityService.swift */,
);
path = Services;
sourceTree = "<group>";
@@ -1447,7 +1482,9 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
7C1C08AA2C31F2F800569B45 /* PauseTorrentIntent.swift in Sources */,
7C5FBE442BC0ADC90069E5A0 /* ProgressWidgetLiveActivity.swift in Sources */,
7CBDBAAE2C31EF52008C986B /* UserDefaults+AppGroup.swift in Sources */,
7C5FBE422BC0ADC90069E5A0 /* ProgressWidgetBundle.swift in Sources */,
7C5FBE532BC0B2780069E5A0 /* SpeedFormat.swift in Sources */,
7C5FBE562BC0B35C0069E5A0 /* ProgressWidgetAttributes.swift in Sources */,
@@ -1493,6 +1530,7 @@
7C5FBE702BC2EF8B0069E5A0 /* EditTextViewController.swift in Sources */,
7CFEBEA02BC6F3CD0013233F /* Date+Extensions.swift in Sources */,
D1DB718B2BD91606007F9267 /* ImageCache.swift in Sources */,
7CBDBAAD2C31EF0C008C986B /* UserDefaults+AppGroup.swift in Sources */,
D1B1BEC92AFE25AE0030C2A4 /* TorrentTrackersViewController.swift in Sources */,
D1DB718D2BD9165C007F9267 /* ImageLoader.swift in Sources */,
7C5FBE292BBDD4030069E5A0 /* PRColorPickerViewModel.swift in Sources */,
@@ -1548,6 +1586,7 @@
D135C5992AEFB96100440680 /* TorrentDetailsViewController.swift in Sources */,
D1048D8B2BBEB6DE0027EF2F /* CombineLatest.swift in Sources */,
D11138572AF976CE008907F7 /* TorrentAddDirectoryItemViewModel.swift in Sources */,
7C1C08AB2C31F31900569B45 /* PauseTorrentIntent.swift in Sources */,
D1CAB8872AF3B52E00EB6AFF /* ToggleCellViewModel.swift in Sources */,
D11BE5492AFBA03D00780C1B /* PRButtonView.swift in Sources */,
7CFEBE8B2BC439CF0013233F /* RssChannelItemCellViewModel.swift in Sources */,
@@ -1556,6 +1595,7 @@
D1352D322BBD720C00104E7B /* PRStorageCell.swift in Sources */,
7C5FBE232BBDD1B60069E5A0 /* NSUserDefaultItem.swift in Sources */,
D1EFCD092AF56A4C00D33A7A /* TorrentFilesViewController.swift in Sources */,
7C1C08AD2C31FEA400569B45 /* IntentsService.swift in Sources */,
7CFEBE832BC434A10013233F /* BaseCollectionViewController.swift in Sources */,
D1A5D0CE2BE52728003E05D5 /* AdView+Google.swift in Sources */,
D1352D3A2BBD747100104E7B /* PortionBarLabels.swift in Sources */,
@@ -1,6 +1,9 @@
{
"sourceLanguage" : "en",
"strings" : {
"" : {
},
"%@ of %@ (%@)" : {
"localizations" : {
"en" : {
@@ -1156,6 +1159,54 @@
}
}
},
"intent.pauseTorrent.hash.description" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Hash of the Torrent that needs to be stopped"
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "Хэш торрента который требуется остановить"
}
}
}
},
"intent.pauseTorrent.hash.title" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Torrent hash"
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "Хэш торрента"
}
}
}
},
"intent.pauseTorrent.title" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Pause Torrent"
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "Остановить торрент"
}
}
}
},
"iTorrent" : {
"localizations" : {
"en" : {
@@ -29,6 +29,7 @@ class SceneDelegate: MvvmSceneDelegate {
container.registerDaemon(factory: RssFeedProvider.init)
container.registerDaemon(factory: WebServerService.init)
container.registerDaemon(factory: LiveActivityService.init)
container.registerDaemon(factory: IntentsService.init)
container.registerDaemon(factory: AdsManager.init)
}
@@ -0,0 +1,26 @@
//
// PauseTorrentIntent.swift
// iTorrent
//
// Created by Даниил Виноградов on 30.06.2024.
//
import AppIntents
extension NSNotification.Name {
static var pauseTorrent: Self {
.init("pauseTorrent")
}
}
struct PauseTorrentIntent: LiveActivityIntent {
static var title: LocalizedStringResource = "intent.pauseTorrent.title"
@Parameter(title: "intent.pauseTorrent.hash.title", description: "intent.pauseTorrent.hash.description")
var torrentHash: String
func perform() async throws -> some IntentResult {
NotificationCenter.default.post(name: .pauseTorrent, object: torrentHash)
return .result()
}
}
@@ -0,0 +1,25 @@
//
// IntentsService.swift
// iTorrent
//
// Created by Даниил Виноградов on 30.06.2024.
//
import MvvmFoundation
import Foundation
actor IntentsService {
init() {
disposeBag.bind {
NotificationCenter.default.publisher(for: .pauseTorrent).sink { notification in
guard let hash = notification.object as? String,
let torrentHandle = TorrentService.shared.torrents.first(where: { $0.snapshot.infoHashes.best.hex == hash })
else { return }
torrentHandle.pause()
}
}
}
private let disposeBag = DisposeBag()
}
+14 -3
View File
@@ -13,16 +13,27 @@ import MvvmFoundation
import UIKit
#endif
extension UserDefaults {
@objc dynamic var greetingsCount: Int {
return integer(forKey: "greetingsCount")
}
}
actor LiveActivityService {
init() {
#if canImport(ActivityKit)
disposeBag.bind {
#if canImport(ActivityKit)
TorrentService.shared.updateNotifier
.sink { [unowned self] updateModel in
Task { await updateLiveActivity(with: updateModel.handle.snapshot) }
}
}
UserDefaults.standard.publisher(for: <#T##KeyPath<UserDefaults, Value>#>)
NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification).sink { notification in
notification.
}
#endif
}
}
private let disposeBag = DisposeBag()
@@ -105,7 +116,7 @@ private extension TorrentHandle.State {
}
var shouldShowLiveActivity: Bool {
let notShow: [Self] = [ .finished, .paused ]
let notShow: [Self] = [.finished, .paused]
return !notShow.contains(self)
}
}
@@ -0,0 +1,112 @@
//
// LiveActivityService.swift
// iTorrent
//
// Created by Даниил Виноградов on 06.04.2024.
//
#if canImport(ActivityKit)
import ActivityKit
import Combine
import LibTorrent
import MvvmFoundation
import UIKit
#endif
actor LiveActivityService {
init() {
disposeBag.bind {
#if canImport(ActivityKit)
TorrentService.shared.updateNotifier
.sink { [unowned self] updateModel in
Task { await updateLiveActivity(with: updateModel.handle.snapshot) }
}
#endif
}
}
private let disposeBag = DisposeBag()
@Injected private var torrentService: TorrentService
}
#if canImport(ActivityKit)
private extension LiveActivityService {
func updateLiveActivity(with snapshot: TorrentHandle.Snapshot) async {
if #available(iOS 16.1, *) {
guard ActivityAuthorizationInfo().areActivitiesEnabled
else { return }
for activity in Activity<ProgressWidgetAttributes>.activities {
if activity.attributes.name == snapshot.name {
if snapshot.friendlyState.shouldShowLiveActivity {
if #available(iOS 16.2, *) {
await activity.update(.init(state: snapshot.toLiveActivityState, staleDate: .now + 10))
} else {
await activity.update(using: snapshot.toLiveActivityState)
}
return
} else {
await activity.end(dismissalPolicy: .immediate)
return
}
}
}
if snapshot.friendlyState.shouldShowLiveActivity {
showLiveActivity(with: snapshot)
}
}
}
func showLiveActivity(with snapshot: TorrentHandle.Snapshot) {
if #available(iOS 16.1, *) {
let attributes = ProgressWidgetAttributes(name: snapshot.name, hash: snapshot.infoHashes.best.hex)
do {
_ = try Activity<ProgressWidgetAttributes>.request(attributes: attributes, contentState: snapshot.toLiveActivityState, pushType: .none)
} catch {
print(error.localizedDescription)
}
}
}
}
private extension TorrentHandle.Snapshot {
var toLiveActivityState: ProgressWidgetAttributes.ContentState {
.init(state: friendlyState.toState,
progress: progress,
downSpeed: downloadRate,
upSpeed: uploadRate,
timeRemainig: timeRemains,
timeStamp: Date())
}
}
private extension TorrentHandle.State {
var toState: ProgressWidgetAttributes.State {
switch self {
case .checkingFiles:
return .checkingFiles
case .downloadingMetadata:
return .downloadingMetadata
case .downloading:
return .downloading
case .finished:
return .finished
case .seeding:
return .seeding
case .checkingResumeData:
return .checkingResumeData
case .paused:
return .paused
@unknown default:
fatalError("\(ProgressWidgetAttributes.State.self) has no such case \(self)")
}
}
var shouldShowLiveActivity: Bool {
let notShow: [Self] = [.finished, .paused]
return !notShow.contains(self)
}
}
#endif
+1 -1
View File
@@ -33,7 +33,7 @@ struct NSUserDefaultItem<Value: NSObject & NSCoding> {
}
private extension NSUserDefaultItem {
static var userDefaults: UserDefaults { UserDefaults(suiteName: "group.itorrent.life-activity") ?? .standard }
static var userDefaults: UserDefaults { .itorrentGroup }
static func value(for key: String) -> Value? {
guard let data = userDefaults.data(forKey: key)
+1 -1
View File
@@ -39,7 +39,7 @@ struct UserDefaultItem<T: Codable> {
}
private extension UserDefaultItem {
static var userDefaults: UserDefaults { UserDefaults(suiteName: "group.itorrent.life-activity") ?? .standard }
static var userDefaults: UserDefaults { .itorrentGroup }
static func get(by key: String) -> T? {
guard let decoded = userDefaults.data(forKey: key),
@@ -0,0 +1,14 @@
//
// UserDefaults+AppGroup.swift
// iTorrent
//
// Created by Даниил Виноградов on 30.06.2024.
//
import Foundation
extension UserDefaults {
static var itorrentGroup: UserDefaults {
UserDefaults(suiteName: "group.itorrent.life-activity") ?? .standard
}
}