This commit is contained in:
Krzysztof Siejkowski
2026-01-09 17:21:06 +01:00
parent d4e0106494
commit 0743428f33
28 changed files with 355 additions and 205 deletions
@@ -29,8 +29,8 @@ public enum ExternalFeatureFlag: String, CaseIterable, Codable {
case driveDDKIntelEnabled = "DriveDDKIntelEnabled"
case driveDDKDisabled = "DriveDDKDisabled"
case driveMacSyncRecoveryDisabled = "DriveMacSyncRecoveryDisabled"
case driveMacKeepDownloadedDisabled = "DriveMacKeepDownloadedDisabled"
case driveMacPromoBannerDisabled = "DriveMacPromoBannerDisabled"
case driveMacGradualRolloutChannelEnabled = "DriveMacGradualRolloutChannelEnabled"
// Sharing
case driveSharingMigration = "DriveSharingMigration"
@@ -149,8 +149,8 @@ class ExternalFeatureFlagsRepository: FeatureFlagsRepository {
case .driveDDKIntelEnabled: return .driveDDKIntelEnabled
case .driveDDKDisabled: return .driveDDKDisabled
case .driveMacSyncRecoveryDisabled: return .driveMacSyncRecoveryDisabled
case .driveMacKeepDownloadedDisabled: return .driveMacKeepDownloadedDisabled
case .driveMacPromoBannerDisabled: return .driveMacPromoBannerDisabled
case .driveMacGradualRolloutChannelEnabled: return .driveMacGradualRolloutChannelEnabled
// Sharing
case .driveSharingMigration: return .driveSharingMigration
case .driveSharingInvitations: return .driveSharingInvitations
@@ -38,8 +38,8 @@ extension LocalSettings: ExternalFeatureFlagsStore {
case .driveDDKIntelEnabled: driveDDKIntelEnabled = value
case .driveDDKDisabled: driveDDKDisabled = value
case .driveMacSyncRecoveryDisabled: driveMacSyncRecoveryDisabled = value
case .driveMacKeepDownloadedDisabled: driveMacKeepDownloadedDisabled = value
case .driveMacPromoBannerDisabled: driveMacPromoBannerDisabled = value
case .driveMacGradualRolloutChannelEnabled: driveMacGradualRolloutChannelEnabled = value
// Sharing
case .driveSharingMigration: driveSharingMigration = value
case .driveSharingInvitations: driveSharingInvitations = value
@@ -103,8 +103,8 @@ extension LocalSettings: ExternalFeatureFlagsStore {
case .driveDDKIntelEnabled: return driveDDKIntelEnabled
case .driveDDKDisabled: return driveDDKDisabled
case .driveMacSyncRecoveryDisabled: return driveMacSyncRecoveryDisabled
case .driveMacKeepDownloadedDisabled: return driveMacKeepDownloadedDisabled
case .driveMacPromoBannerDisabled: return driveMacPromoBannerDisabled
case .driveMacGradualRolloutChannelEnabled: return driveMacGradualRolloutChannelEnabled
// Sharing
case .driveSharingMigration: return driveSharingMigration
case .driveSharingInvitations: return driveSharingInvitations
@@ -32,8 +32,8 @@ public enum FeatureAvailabilityFlag: CaseIterable {
case driveDDKIntelEnabled
case driveDDKDisabled
case driveMacSyncRecoveryDisabled
case driveMacKeepDownloadedDisabled
case driveMacPromoBannerDisabled
case driveMacGradualRolloutChannelEnabled
// Sharing
case driveSharingMigration
+7 -12
View File
@@ -60,13 +60,12 @@ public class LocalSettings: NSObject {
@SettingsStorage("DriveDDKIntelEnabled") public var driveDDKIntelEnabledValue: Bool?
@SettingsStorage("DriveDDKDisabled") public var driveDDKDisabledValue: Bool?
@SettingsStorage("DriveMacSyncRecoveryDisabled") public var driveMacSyncRecoveryDisabledValue: Bool?
@SettingsStorage("DriveMacKeepDownloadedDisabled") public var driveMacKeepDownloadedDisabledValue: Bool?
@SettingsStorage("DriveMacPromoBannerDisabled") public var driveMacPromoBannerDisabledValue: Bool?
@SettingsStorage("DriveMacGradualRolloutChannelEnabled") public var driveMacGradualRolloutChannelEnabledValue: Bool?
@SettingsStorage("DriveAlbumsDisabled") public var driveAlbumsDisabledValue: Bool?
@SettingsStorage("DriveCopyDisabled") public var driveCopyDisabledValue: Bool?
@SettingsStorage("photoVolumeMigrationLastShownDate") public var photoVolumeMigrationLastShownDate: Date?
@SettingsStorage("QuotaState") public var quotaStateValue: Int?
@SettingsStorage("domainVersion") public var domainVersionValue: Data?
@SettingsStorage("DrivePhotosTagsMigration") public var drivePhotosTagsMigrationValue: Bool?
@SettingsStorage("DrivePhotosTagsMigrationDisabled") public var drivePhotosTagsMigrationDisabledValue: Bool?
@@ -184,15 +183,14 @@ public class LocalSettings: NSObject {
self._driveDDKIntelEnabledValue.configure(with: suite)
self._driveDDKDisabledValue.configure(with: suite)
self._driveMacSyncRecoveryDisabledValue.configure(with: suite)
self._driveMacKeepDownloadedDisabledValue.configure(with: suite)
self._driveMacPromoBannerDisabledValue.configure(with: suite)
self._driveMacGradualRolloutChannelEnabledValue.configure(with: suite)
self._didFetchFeatureFlags.configure(with: suite)
self._promotedNewFeaturesValue.configure(with: suite)
self._driveAlbumsDisabledValue.configure(with: suite)
self._driveCopyDisabledValue.configure(with: suite)
self._photoVolumeMigrationLastShownDate.configure(with: suite)
self._quotaStateValue.configure(with: suite)
self._domainVersionValue.configure(with: suite)
self._drivePhotosTagsMigrationValue.configure(with: suite)
self._drivePhotosTagsMigrationDisabledValue.configure(with: suite)
self._tagsMigrationFinishedValue.configure(with: suite)
@@ -305,7 +303,6 @@ public class LocalSettings: NSObject {
driveDDKIntelEnabled = driveDDKIntelEnabledValue ?? false
driveDDKDisabled = driveDDKDisabledValue ?? false
driveMacSyncRecoveryDisabled = driveMacSyncRecoveryDisabledValue ?? false
driveMacKeepDownloadedDisabled = driveMacKeepDownloadedDisabledValue ?? false
didEnableComputers = didEnableComputersValue ?? false
driveiOSComputers = driveiOSComputersValue ?? false
driveiOSComputersDisabled = driveiOSComputersDisabledValue ?? false
@@ -344,7 +341,6 @@ public class LocalSettings: NSObject {
self.optOutFromCrashReports = nil
self.userId = nil
self.didFetchFeatureFlags = nil
self.domainVersionValue = nil
// self.isOnboardedValue needs no clean up - we only show it for first login ever
// self.isUpsellShownValue needs no clean up - we only show it once
// self.isPhotoUpsellShownValue needs no clean up - we only show it once
@@ -365,7 +361,6 @@ public class LocalSettings: NSObject {
self.driveDDKIntelEnabledValue = nil
self.driveDDKDisabledValue = nil
self.driveMacSyncRecoveryDisabledValue = nil
self.driveMacKeepDownloadedDisabledValue = nil
self.pushNotificationIsEnabledValue = nil
self.keepScreenAwakeBannerHasDismissed = nil
self.didShowPhotosNotification = nil
@@ -660,16 +655,16 @@ public class LocalSettings: NSObject {
set { driveMacSyncRecoveryDisabledValue = newValue }
}
public var driveMacKeepDownloadedDisabled: Bool {
get { driveMacKeepDownloadedDisabledValue ?? false }
set { driveMacKeepDownloadedDisabledValue = newValue }
}
public var driveMacPromoBannerDisabled: Bool {
get { driveMacPromoBannerDisabledValue ?? false }
set { driveMacPromoBannerDisabledValue = newValue }
}
public var driveMacGradualRolloutChannelEnabled: Bool {
get { driveMacGradualRolloutChannelEnabledValue ?? false }
set { driveMacGradualRolloutChannelEnabledValue = newValue }
}
public var ratingIOSDrive: Bool {
get { ratingIOSDriveValue ?? false }
set { ratingIOSDriveValue = newValue }
@@ -48,6 +48,7 @@ public protocol FeatureFlagsControllerProtocol {
var hasSDKDownloadMain: Bool { get }
var hasSDKDownloadPhoto: Bool { get }
var hasIOSBlackFriday2025: Bool { get }
var hasGradualRolloutChannel: Bool { get }
/// Makes current value publisher for the specific FF
func makePublisher(keyPath: KeyPath<FeatureFlagsControllerProtocol, Bool>) -> AnyPublisher<Bool, Never>
}
@@ -181,6 +182,10 @@ public final class FeatureFlagsController: FeatureFlagsControllerProtocol {
featureFlagsStore.isFeatureEnabled(.driveIOSBlackFriday2025)
}
public var hasGradualRolloutChannel: Bool {
featureFlagsStore.isFeatureEnabled(.driveMacGradualRolloutChannelEnabled)
}
public func makePublisher(keyPath: KeyPath<FeatureFlagsControllerProtocol, Bool>) -> AnyPublisher<Bool, Never> {
let currentValue = self[keyPath: keyPath]
let updatePublisher = updatePublisher
+1 -1
View File
@@ -33,7 +33,7 @@ let package = Package(
/// Step 2 - Use the new version
/// a. Update the version number below
/// b. Rebuild the app
.package(url: "https://gitlab.protontech.ch/drive/sdk-swift.git", branch: "0.0.16-ddk"),
.package(url: "https://gitlab.protontech.ch/drive/sdk-swift.git", branch: "0.0.17-ddk"),
/// To use a local build of the DDK during development:
/// 1. In the DDK repo run `./scripts/build_framework.sh`
@@ -1,41 +0,0 @@
// Copyright (c) 2025 Proton AG
//
// This file is part of Proton Drive.
//
// Proton Drive is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Proton Drive is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Proton Drive. If not, see https://www.gnu.org/licenses/.
import FileProvider
import PDCore
public protocol DomainSettings {
var domainVersion: NSFileProviderDomainVersion { get }
func bumpDomainVersion()
}
extension LocalSettings: DomainSettings {
public var domainVersion: NSFileProviderDomainVersion {
if let data = self.domainVersionValue {
if let version = try? NSKeyedUnarchiver.unarchivedObject(ofClass: NSFileProviderDomainVersion.self, from: data) {
return version
}
}
return NSFileProviderDomainVersion()
}
public func bumpDomainVersion() {
let version = self.domainVersion
self.domainVersionValue = try? NSKeyedArchiver.archivedData(withRootObject: version.next(), requiringSecureCoding: true)
}
}
@@ -23,7 +23,6 @@ public extension UserDefaults {
case workingSetEnumerationInProgressKey = "workingSetEnumerationInProgress"
case pathsMarkedAsKeepDownloadedKey = "pathsMarkedAsKeepDownloaded"
case pathsMarkedAsOnlineOnlyKey = "pathsMarkedAsOnlineOnly"
case isKeepDownloadedEnabledKey = "isKeepDownloadedEnabled"
case openItemsInBrowserKey = "openItemsInBrowser"
case extensionPathKey = "fileProviderExtensionPath"
}
@@ -1825,7 +1825,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 2.10.1;
MARKETING_VERSION = 2.10.2;
OTHER_CODE_SIGN_FLAGS = "";
PRODUCT_BUNDLE_IDENTIFIER = ch.protonmail.drive;
PRODUCT_MODULE_NAME = ProtonDriveMac;
@@ -1912,7 +1912,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 2.10.1;
MARKETING_VERSION = 2.10.2;
OTHER_CODE_SIGN_FLAGS = "";
PRODUCT_BUNDLE_IDENTIFIER = ch.protonmail.drive;
PRODUCT_MODULE_NAME = ProtonDriveMac;
@@ -1983,7 +1983,7 @@
"@executable_path/../../../../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 2.10.1;
MARKETING_VERSION = 2.10.2;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
OTHER_CODE_SIGN_FLAGS = "";
@@ -32,7 +32,6 @@ class FileProviderExtension: NSObject, NSFileProviderReplicatedExtension {
@SettingsStorage(UserDefaults.FileProvider.workingSetEnumerationInProgressKey.rawValue) var workingSetEnumerationInProgress: Bool?
@SettingsStorage(UserDefaults.FileProvider.shouldReenumerateItemsKey.rawValue) var shouldReenumerateItems: Bool?
@SettingsStorage("domainDisconnectedReasonCacheReset") public var cacheReset: Bool?
@SettingsStorage(UserDefaults.FileProvider.isKeepDownloadedEnabledKey.rawValue) var isKeepDownloadedEnabledAccordingToExtension: Bool?
@SettingsStorage(UserDefaults.FileProvider.pathsMarkedAsKeepDownloadedKey.rawValue) var pathsMarkedAsKeepDownloaded: String?
@SettingsStorage(UserDefaults.FileProvider.pathsMarkedAsOnlineOnlyKey.rawValue) var pathsMarkedAsOnlineOnly: String?
@SettingsStorage(UserDefaults.FileProvider.openItemsInBrowserKey.rawValue) var openItemsInBrowser: String?
@@ -82,12 +81,6 @@ class FileProviderExtension: NSObject, NSFileProviderReplicatedExtension {
return true
}
private var isKeepDownloadedEnabled: Bool {
tower.featureFlags.isEnabled(flag: .driveMacKeepDownloadedDisabled) != true
}
private var domainSettings: DomainSettings
private var isForceRefreshing: Bool = false
private let domain: NSFileProviderDomain // use domain to support multiple accounts
@@ -188,9 +181,6 @@ class FileProviderExtension: NSObject, NSFileProviderReplicatedExtension {
_cacheReset.configure(with: Constants.appGroup)
_fileProviderExtensionPath.configure(with: Constants.appGroup)
updateLastLineBeforeHanging()
domainSettings = LocalSettings.shared
updateLastLineBeforeHanging()
self.observationCenter = UserDefaultsObservationCenter(userDefaults: Constants.appGroup.userDefaults)
updateLastLineBeforeHanging()
@@ -246,10 +236,8 @@ class FileProviderExtension: NSObject, NSFileProviderReplicatedExtension {
self.setUpKeepDownloadedObservers()
updateLastLineBeforeHanging()
let hasKeepDownloadedStateChanged = handleKeepDownloadedStateChange()
updateLastLineBeforeHanging()
self.reenumerateIfNecessary(hasKeepDownloadedStateChanged: hasKeepDownloadedStateChanged)
self.reenumerateIfNecessary()
updateLastLineBeforeHanging()
postExtensionLaunchNotification()
@@ -371,8 +359,8 @@ class FileProviderExtension: NSObject, NSFileProviderReplicatedExtension {
}
}
private func reenumerateIfNecessary(hasKeepDownloadedStateChanged: Bool) {
if shouldReenumerateItems == true || workingSetEnumerationInProgress == true || hasKeepDownloadedStateChanged == true {
private func reenumerateIfNecessary() {
if shouldReenumerateItems == true || workingSetEnumerationInProgress == true {
manager.signalEnumerator(for: .workingSet) { [weak self] error in
guard let error else { return }
let sei = self?.shouldReenumerateItems.map(\.description) ?? "nil"
@@ -383,20 +371,6 @@ class FileProviderExtension: NSObject, NSFileProviderReplicatedExtension {
}
}
private func handleKeepDownloadedStateChange() -> Bool {
let oldState = isKeepDownloadedEnabledAccordingToExtension ?? false
let newState = tower.featureFlags.isEnabled(flag: .driveMacKeepDownloadedDisabled) != true
if oldState != newState {
isKeepDownloadedEnabledAccordingToExtension = newState
initialServices.localSettings.bumpDomainVersion()
return true
} else {
return false
}
}
deinit {
observationCenter.removeObserver(self)
Log.info("FileProviderExtension deinit: \(instanceIdentifier.uuidString)", domain: .syncing)
@@ -922,14 +896,3 @@ extension FileProviderExtension: NSFileProviderCustomAction {
}
// swiftlint:enable function_parameter_count
extension FileProviderExtension: NSFileProviderDomainState {
public var domainVersion: NSFileProviderDomainVersion {
domainSettings.domainVersion
}
// Used to enable/disable actions defined in `info.plist`
public var userInfo: [AnyHashable: Any] {
return ["keepDownloadedEnabled": isKeepDownloadedEnabled]
}
}
@@ -30,15 +30,13 @@
<true/>
<key>NSExtension</key>
<dict>
<key>NSExtensionFileProviderDocumentGroup</key>
<string>$(TeamIdentifierPrefix)ch.protonmail.protondrive</string>
<key>NSExtensionAttributes</key>
<dict/>
<key>NSExtensionFileProviderActions</key>
<array>
<dict>
<key>NSExtensionFileProviderActionActivationRule</key>
<string>domainUserInfo.keepDownloadedEnabled == 1 &amp;&amp; SUBQUERY ( fileproviderItems, $item, $item.itemIdentifier != &quot;NSFileProviderRootContainerItemIdentifier&quot; &amp;&amp; $item.userInfo.keep_downloaded != YES &amp;&amp; $item.userInfo.inherit_keep_downloaded != YES ).@count &gt; 0</string>
<string>SUBQUERY ( fileproviderItems, $item, $item.itemIdentifier != "NSFileProviderRootContainerItemIdentifier" &amp;&amp; $item.userInfo.keep_downloaded != YES &amp;&amp; $item.userInfo.inherit_keep_downloaded != YES ).@count &gt; 0</string>
<key>NSExtensionFileProviderActionIdentifier</key>
<string>ch.protonmail.drive.fileprovider.action.keep_downloaded</string>
<key>NSExtensionFileProviderActionName</key>
@@ -46,7 +44,7 @@
</dict>
<dict>
<key>NSExtensionFileProviderActionActivationRule</key>
<string>SUBQUERY ( fileproviderItems, $item, ($item.itemIdentifier != &quot;NSFileProviderRootContainerItemIdentifier&quot; &amp;&amp; $item.isUploaded == YES &amp;&amp; ($item.isDownloaded == YES || $item.isDownloading == YES) ) ).@count &gt; 0</string>
<string>SUBQUERY ( fileproviderItems, $item, ($item.itemIdentifier != "NSFileProviderRootContainerItemIdentifier" &amp;&amp; $item.isUploaded == YES &amp;&amp; ($item.isDownloaded == YES || $item.isDownloading == YES) ) ).@count &gt; 0</string>
<key>NSExtensionFileProviderActionIdentifier</key>
<string>ch.protonmail.drive.fileprovider.action.remove_download</string>
<key>NSExtensionFileProviderActionName</key>
@@ -54,7 +52,7 @@
</dict>
<dict>
<key>NSExtensionFileProviderActionActivationRule</key>
<string>SUBQUERY ( fileproviderItems, $item, ($item.itemIdentifier != &quot;NSFileProviderRootContainerItemIdentifier&quot; ) ).@count &gt; 0</string>
<string>SUBQUERY ( fileproviderItems, $item, ($item.itemIdentifier != "NSFileProviderRootContainerItemIdentifier" ) ).@count &gt; 0</string>
<key>NSExtensionFileProviderActionIdentifier</key>
<string>ch.protonmail.drive.fileprovider.action.open_in_browser</string>
<key>NSExtensionFileProviderActionName</key>
@@ -69,6 +67,24 @@
<string>Refresh</string>
</dict>
</array>
<key>NSExtensionFileProviderAllowsContextualMenuDownloadEntry</key>
<false/>
<key>NSExtensionFileProviderAllowsUserControlledEviction</key>
<false/>
<key>NSExtensionFileProviderDocumentGroup</key>
<string>$(TeamIdentifierPrefix)ch.protonmail.protondrive</string>
<key>NSExtensionFileProviderDownloadPipelineDepth</key>
<integer>8</integer>
<key>NSExtensionFileProviderSupportsEnumeration</key>
<true/>
<key>NSExtensionFileProviderUploadPipelineDepth</key>
<integer>8</integer>
<key>NSExtensionFileProviderWantsFlattenedPackages</key>
<true/>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.fileprovider-nonui</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).FileProviderExtension</string>
<key>NSFileProviderDecorations</key>
<array>
<dict>
@@ -112,22 +128,6 @@
<string></string>
</dict>
</array>
<key>NSExtensionFileProviderAllowsContextualMenuDownloadEntry</key>
<false/>
<key>NSExtensionFileProviderAllowsUserControlledEviction</key>
<false/>
<key>NSExtensionFileProviderSupportsEnumeration</key>
<true/>
<key>NSExtensionFileProviderWantsFlattenedPackages</key>
<true/>
<key>NSExtensionFileProviderDownloadPipelineDepth</key>
<integer>8</integer>
<key>NSExtensionFileProviderUploadPipelineDepth</key>
<integer>8</integer>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.fileprovider-nonui</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).FileProviderExtension</string>
</dict>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
@@ -173,7 +173,10 @@ class AppCoordinator: NSObject, ObservableObject {
let promoCampaignInteractor = PromoCampaignInteractor.shared
#if HAS_BUILTIN_UPDATER
let appUpdateService = SparkleAppUpdateService()
let featureFlagsStore = initialServices.localSettings
let appUpdateService = SparkleAppUpdateService(
gradualRolloutEnabled: featureFlagsStore.isFeatureEnabled(.driveMacGradualRolloutChannelEnabled)
)
#else
let appUpdateService: AppUpdateServiceProtocol? = nil
#endif
@@ -198,6 +198,11 @@ class AppDelegate: NSObject, NSApplicationDelegate {
}
}
}
func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows: Bool) -> Bool {
coordinator?.toggleStatusWindow(onlyOpen: true)
return false
}
}
extension AppDelegate: UNUserNotificationCenterDelegate {
@@ -0,0 +1,15 @@
{
"images" : [
{
"filename" : "status-signed-out-update-available.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"template-rendering-intent" : "template"
}
}
@@ -0,0 +1,131 @@
%PDF-1.7
1 0 obj
<< >>
endobj
2 0 obj
<< /Length 3 0 R >>
stream
/DeviceRGB CS
/DeviceRGB cs
q
1.000000 0.000000 -0.000000 1.000000 0.000000 7.000000 cm
0.000000 0.000000 0.000000 scn
11.607819 0.000000 m
14.093100 0.000000 16.107819 2.014719 16.107819 4.500000 c
16.107819 6.985281 14.093100 9.000000 11.607819 9.000000 c
9.122538 9.000000 7.107819 6.985281 7.107819 4.500000 c
7.107819 2.014719 9.122538 0.000000 11.607819 0.000000 c
h
12.056848 6.554014 m
12.056848 6.815659 11.868741 7.000000 11.601750 7.000000 c
11.340828 7.000000 11.146654 6.815659 11.146654 6.554014 c
11.146654 4.580773 l
11.195197 3.326066 l
10.588401 4.039643 l
9.866314 4.753221 l
9.781363 4.836472 9.672139 4.884044 9.544712 4.884044 c
9.295925 4.884044 9.107819 4.699703 9.107819 4.455897 c
9.107819 4.331021 9.144226 4.223984 9.223110 4.146680 c
11.249809 2.172448 l
11.371168 2.053518 11.474323 2.000000 11.601750 2.000000 c
11.735246 2.000000 11.844469 2.059465 11.959761 2.172448 c
13.980392 4.146680 l
14.059275 4.223984 14.107819 4.331021 14.107819 4.455897 c
14.107819 4.699703 13.913644 4.884044 13.664858 4.884044 c
13.531363 4.884044 13.422139 4.842418 13.343256 4.753221 c
12.627236 4.039643 l
12.008305 3.320119 l
12.056848 4.580773 l
12.056848 6.554014 l
h
f*
n
Q
q
1.000000 0.000000 -0.000000 1.000000 0.000000 5.000000 cm
0.000000 0.000000 0.000000 scn
13.001020 -3.281252 m
13.001020 1.208868 l
12.678776 1.117203 12.344465 1.054240 12.001020 1.022916 c
12.001020 -3.281252 l
12.001020 -3.678205 11.679226 -4.000000 11.282270 -4.000000 c
10.996668 -4.000000 l
10.996668 1.022415 l
9.494219 1.157925 8.166166 1.898870 7.257843 2.999666 c
4.676876 2.999666 l
4.378308 2.999666 4.091987 3.118351 3.880975 3.329577 c
3.834098 3.376502 l
3.435521 3.775486 2.894691 3.999667 2.330730 3.999667 c
1.005683 3.999667 l
1.005903 4.281811 l
1.006213 4.678546 1.327917 5.000000 1.724653 5.000000 c
3.113554 5.000000 l
3.279167 5.000000 3.441723 4.955386 3.584134 4.870847 c
4.509006 4.321819 l
4.805897 4.145577 5.144784 4.052567 5.490046 4.052567 c
6.574296 4.052567 l
6.416604 4.369721 6.288605 4.704270 6.193940 5.052567 c
5.490046 5.052567 l
5.324432 5.052567 5.161877 5.097182 5.019465 5.181721 c
4.094594 5.730749 l
3.797702 5.906991 3.458816 6.000000 3.113554 6.000000 c
1.724653 6.000000 l
0.775937 6.000000 0.006644 5.231306 0.005903 4.282591 c
0.000001 -3.279908 l
-0.000740 -4.229671 0.768986 -5.000000 1.718749 -5.000000 c
11.282270 -5.000000 l
12.231509 -5.000000 13.001020 -4.230492 13.001020 -3.281252 c
h
f
n
Q
endstream
endobj
3 0 obj
2446
endobj
4 0 obj
<< /Annots []
/Type /Page
/MediaBox [ 0.000000 0.000000 16.107819 16.000000 ]
/Resources 1 0 R
/Contents 2 0 R
/Parent 5 0 R
>>
endobj
5 0 obj
<< /Kids [ 4 0 R ]
/Count 1
/Type /Pages
>>
endobj
6 0 obj
<< /Pages 5 0 R
/Type /Catalog
>>
endobj
xref
0 7
0000000000 65535 f
0000000010 00000 n
0000000034 00000 n
0000002536 00000 n
0000002559 00000 n
0000002732 00000 n
0000002806 00000 n
trailer
<< /ID [ (some) (id) ]
/Root 6 0 R
/Size 7
>>
startxref
2865
%%EOF
+2 -2
View File
@@ -101,8 +101,6 @@
<array>
<string>com.apple.icon-decoration.badge</string>
</array>
<key>UTTypeIdentifier</key>
<string>me.proton.drive.badge.checkmark</string>
<key>UTTypeDescription</key>
<string>Keep Downloaded</string>
<key>UTTypeIcons</key>
@@ -110,6 +108,8 @@
<key>UTTypeIconName</key>
<string>badge-checkmark</string>
</dict>
<key>UTTypeIdentifier</key>
<string>me.proton.drive.badge.checkmark</string>
</dict>
<dict>
<key>UTTypeConformsTo</key>
@@ -21,7 +21,7 @@ import SwiftUI
import PDCore
import Combine
struct PromoCampaignConfiguration {
struct PromoCampaignConfiguration: Comparable {
enum BannerIcon {
case drivePlus
case discount
@@ -36,40 +36,24 @@ struct PromoCampaignConfiguration {
}
}
enum TimeRange {
enum TimeRange: Comparable {
/// Campaign should only be active while start < date < end
case limitedTime(start: Date, end: Date)
/// Campaign should be active after the given date
case indefinite(after: Date)
/// Campaign should always be active (useful for testing!)
case always
static func < (lhs: TimeRange, rhs: TimeRange) -> Bool {
switch (lhs, rhs) {
case (.limitedTime(let lStart, _), .limitedTime(let rStart, _)),
(.limitedTime(let lStart, _), .indefinite(let rStart)),
(.indefinite(let lStart), .limitedTime(let rStart, _)),
(.indefinite(let lStart), .indefinite(let rStart)):
return lStart < rStart
}
}
}
fileprivate static let activeCampaigns: [PromoCampaignConfiguration] = [
PromoCampaignConfiguration(
campaignId: "bf-25-stage-1",
timeRange: .limitedTime(
start: Date(timeIntervalSinceReferenceDate: 783860400), // 2025-11-03 12:00 CET
end: Date(timeIntervalSinceReferenceDate: 785156400) // 2025-11-18 12:00 CET
),
backgroundColor: Color(hex: "#D8FF00"),
tintColor: Color(hex: "#291C5D"),
icon: .discount,
text: "Black Friday: 50% off",
resetsPreviousDismissal: false
),
PromoCampaignConfiguration(
campaignId: "bf-25-stage-2",
timeRange: .limitedTime(
start: Date(timeIntervalSinceReferenceDate: 785156400), // 2025-11-18 12:00 CET
end: Date(timeIntervalSinceReferenceDate: 786452400) // 2025-12-03 12:00 CET
),
backgroundColor: Color(hex: "#D8FF00"),
tintColor: Color(hex: "#291C5D"),
icon: .discount,
text: "Black Friday: 80% off",
resetsPreviousDismissal: true
),
PromoCampaignConfiguration(
campaignId: "upgrade-drive-plus",
timeRange: .indefinite(
@@ -79,7 +63,8 @@ struct PromoCampaignConfiguration {
tintColor: ColorProvider.White,
icon: .drivePlus,
text: "Upgrade to Drive Plus",
resetsPreviousDismissal: false
resetsPreviousDismissal: false,
displaysOnStatusBar: false
)
]
@@ -90,6 +75,11 @@ struct PromoCampaignConfiguration {
let icon: BannerIcon
let text: String
let resetsPreviousDismissal: Bool
let displaysOnStatusBar: Bool
static func < (lhs: PromoCampaignConfiguration, rhs: PromoCampaignConfiguration) -> Bool {
lhs.timeRange < rhs.timeRange
}
}
protocol PromoCampaignInteractorProtocol {
@@ -106,14 +96,16 @@ final class PromoCampaignInteractor: PromoCampaignInteractorProtocol {
@SettingsStorage(UserDefaults.PromoCampaign.hasDismissedBanner.rawValue) private var hasDismissedBanner: Bool?
@SettingsStorage(UserDefaults.PromoCampaign.lastSeenCampaignId.rawValue) private var lastSeenCampaign: String?
private let activeCampaigns: [PromoCampaignConfiguration]
private var currentlyActiveCampaign = CurrentValueSubject<PromoCampaignConfiguration?, Never>(nil)
private let dateResource: DateResource
static let shared = PromoCampaignInteractor()
init(dateResource: DateResource) {
init(dateResource: DateResource, activeCampaigns: [PromoCampaignConfiguration]) {
self.dateResource = dateResource
self.activeCampaigns = activeCampaigns
_hasDismissedBanner.configure(with: Constants.appGroup)
_lastSeenCampaign.configure(with: Constants.appGroup)
@@ -122,7 +114,10 @@ final class PromoCampaignInteractor: PromoCampaignInteractorProtocol {
}
private convenience init() {
self.init(dateResource: PromoCampaignDateResource())
self.init(
dateResource: PromoCampaignDateResource(),
activeCampaigns: PromoCampaignConfiguration.activeCampaigns
)
}
func refreshCampaign(forceResetBannerDismissal: Bool = false) {
@@ -146,7 +141,7 @@ final class PromoCampaignInteractor: PromoCampaignInteractorProtocol {
}
private func getActiveCampaign() -> PromoCampaignConfiguration? {
PromoCampaignConfiguration.activeCampaigns.first { campaign in
activeCampaigns.sorted().first { campaign in
let currentDate = dateResource.getDate()
switch campaign.timeRange {
@@ -154,8 +149,6 @@ final class PromoCampaignInteractor: PromoCampaignInteractorProtocol {
return start <= currentDate && currentDate < end
case let .indefinite(start):
return start <= currentDate
case .always:
return true
}
}
}
@@ -263,15 +263,39 @@ struct QASettingsView: View {
.frame(maxWidth: .infinity)
}
.toggleStyle(SwitchToggleStyle())
Picker(selection: $vm.updateChannel) {
ForEach(AppUpdateChannel.allCases.map(\.rawValue), id: \.self) {
Text($0)
}
} label: {
Text("Select update channel")
HStack {
TextField("Feed URL", text: $vm.providedFeedURL)
Button("Set") { vm.setUpdateFeedURLAndQuit() }
Button("Default") { vm.useDefaultUpdateFeedURL() }
}
.pickerStyle(MenuPickerStyle())
VStack(alignment: .leading, spacing: 8) {
Text("Select update channels (multi-select):")
.font(.system(size: 13))
ForEach(AppUpdateChannel.allCases, id: \.self) { channel in
HStack {
Button {
vm.toggleChannelSelection(channel)
} label: {
HStack {
Image(systemName: vm.selectedUpdateChannels.contains(channel) ? "checkmark.square.fill" : "square")
.foregroundColor(vm.selectedUpdateChannels.contains(channel) ? .blue : .gray)
Text(channel.rawValue)
.foregroundColor(.primary)
}
}
.buttonStyle(.plain)
Spacer()
}
}
Text("Selected: \(vm.selectedUpdateChannels.map(\.rawValue).sorted().joined(separator: ", "))")
.font(.system(size: 11))
.foregroundColor(.secondary)
}
Text(vm.updateMessage)
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
@@ -28,7 +28,8 @@ import ProtonCoreServices
struct QASettingsConstants {
static let shouldUpdateEvenOnDebugBuild = "shouldUpdateEvenOnDebugBuild"
static let shouldUpdateEvenOnTestFlight = "shouldUpdateEvenOnTestFlight"
static let updateChannel = "updateChannel"
static let updateChannels = "updateChannels"
static let updateFeedURL = "updateFeedURL"
static let shouldObfuscateDumpsStorage = "shouldObfuscateDumpsStorage"
static let disconnectDomainOnSignOut = "disconnectDomainOnSignOut"
static let driveDDKEnabledInQASettings = "driveDDKEnabledInQASettings"
@@ -63,13 +64,15 @@ class QASettingsViewModel: ObservableObject {
@Published var shouldUpdateEvenOnTestFlight: Bool = false {
didSet { shouldUpdateEvenOnTestFlightStorage = shouldUpdateEvenOnTestFlight }
}
@Published var updateChannel: String = AppUpdateChannel.stable.rawValue {
didSet { updateChannelStorage = updateChannel }
@Published var selectedUpdateChannels: Set<AppUpdateChannel> = [AppUpdateChannel.stable] {
didSet { updateChannelsStorage = Array(selectedUpdateChannels) }
}
@Published var updateMessage: String = ""
@Published var providedFeedURL: String = ""
@SettingsStorage(QASettingsConstants.shouldUpdateEvenOnDebugBuild) var shouldUpdateEvenOnDebugBuildStorage: Bool?
@SettingsStorage(QASettingsConstants.shouldUpdateEvenOnTestFlight) var shouldUpdateEvenOnTestFlightStorage: Bool?
@SettingsStorage(QASettingsConstants.updateChannel) var updateChannelStorage: String?
@SettingsStorage(QASettingsConstants.updateFeedURL) var updateFeedURL: String?
@SettingsCodableProperty(QASettingsConstants.updateChannels) var updateChannelsStorage: [AppUpdateChannel] = []
#endif
@Published var shouldFetchEvents: Bool = true {
@@ -226,7 +229,8 @@ class QASettingsViewModel: ObservableObject {
#if HAS_BUILTIN_UPDATER
self.shouldUpdateEvenOnDebugBuild = shouldUpdateEvenOnDebugBuildStorage ?? false
self.shouldUpdateEvenOnTestFlight = shouldUpdateEvenOnTestFlightStorage ?? false
self.updateChannel = updateChannelStorage ?? AppUpdateChannel.stable.rawValue
self.providedFeedURL = updateFeedURL ?? "https://proton.me/download/drive/macos/appcast.xml"
self.selectedUpdateChannels = Set(updateChannelsStorage ?? [AppUpdateChannel.stable])
if let appUpdateService {
self.updateMessage = """
Last update check: \(appUpdateService.updater.lastUpdateCheckDate.map(String.init) ?? "never")
@@ -241,11 +245,31 @@ class QASettingsViewModel: ObservableObject {
guard let self else { return }
Constants.appGroup.userDefaults.set(self.environment, forKey: Constants.SettingsBundleKeys.host.rawValue)
await self.signoutManager?.signOutAsync()
_ = await MainActor.run {
exit(0)
}
exit(0)
}
}
#if HAS_BUILTIN_UPDATER
func toggleChannelSelection(_ channel: AppUpdateChannel) {
if selectedUpdateChannels.contains(channel) {
// Don't allow deselecting all channels - at least one must be selected
if selectedUpdateChannels.count > 1 {
selectedUpdateChannels.remove(channel)
}
} else {
selectedUpdateChannels.insert(channel)
}
}
func setUpdateFeedURLAndQuit() {
self.updateFeedURL = providedFeedURL
}
func useDefaultUpdateFeedURL() {
self.providedFeedURL = "https://proton.me/download/drive/macos/appcast.xml"
self.updateFeedURL = nil
}
#endif
func jail() {
Task { [weak self] in
@@ -45,33 +45,28 @@ enum UpdateAvailabilityStatus: Equatable {
case errored(userFacingMessage: String)
}
enum AppUpdateChannel: String, CaseIterable {
enum AppUpdateChannel: String, CaseIterable, Codable {
case stable
case beta
case alpha
#if HAS_QA_FEATURES
// special channels for testing variou update scenarios
case testNoUpdate = "test-no-update"
case testUpdateAvailable = "test-update-available"
case testInvalidUpdate = "test-invalid"
case testKeyRotation = "test-key-rotation"
#endif
case gradualRollout = "gradual-rollout"
}
final class SparkleAppUpdateService: NSObject, AppUpdateServiceProtocol, SPUUpdaterDelegate, SPUStandardUserDriverDelegate {
@Published private(set) var updateAvailability: UpdateAvailabilityStatus
var updateAvailabilityPublisher: AnyPublisher<UpdateAvailabilityStatus, Never> {
self.$updateAvailability.eraseToAnyPublisher()
}
private static let shortUpdateCheckInterval: TimeInterval = 60 * 60 // one hour, minumum possible in Spark
private static let longUpdateCheckInterval: TimeInterval = 24 * 60 * 60 // one day
private let gradualRolloutEnabled: () -> Bool
#if HAS_QA_FEATURES
@SettingsStorage(QASettingsConstants.shouldUpdateEvenOnDebugBuild) private var shouldUpdateEvenOnDebugBuild: Bool?
@SettingsStorage(QASettingsConstants.shouldUpdateEvenOnTestFlight) private var shouldUpdateEvenOnTestFlight: Bool?
@SettingsStorage(QASettingsConstants.updateChannel) private var updateChannel: String?
@SettingsStorage(QASettingsConstants.updateFeedURL) private var updateFeedURL: String?
@SettingsCodableProperty(QASettingsConstants.updateChannels) private var qaOverrideUpdateChannels: [AppUpdateChannel] = []
#endif
private var debugBuild: Bool {
@@ -89,6 +84,9 @@ final class SparkleAppUpdateService: NSObject, AppUpdateServiceProtocol, SPUUpda
private var isUpdateMechanismOn: Bool {
#if HAS_QA_FEATURES
// ensure no update happens for UI tests
if Constants.isInUITests { return false }
let shouldUpdateEvenOnTestFlight = self.shouldUpdateEvenOnTestFlight ?? false
let shouldUpdateEvenOnDebugBuild = self.shouldUpdateEvenOnDebugBuild ?? false
#else
@@ -113,7 +111,8 @@ final class SparkleAppUpdateService: NSObject, AppUpdateServiceProtocol, SPUUpda
private var updaterController: SPUStandardUpdaterController!
#endif
init(updaterController: SPUStandardUpdaterController? = nil) {
init(gradualRolloutEnabled: @autoclosure @escaping () -> Bool, updaterController: SPUStandardUpdaterController? = nil) {
self.gradualRolloutEnabled = gradualRolloutEnabled
self.updateAvailability = .upToDate(version: Constants.versionDigits)
super.init()
if let updaterController {
@@ -160,6 +159,14 @@ final class SparkleAppUpdateService: NSObject, AppUpdateServiceProtocol, SPUUpda
// configuration
extension SparkleAppUpdateService {
func feedURLString(for updater: SPUUpdater) -> String? {
#if HAS_QA_FEATURES
return updateFeedURL
#else
return nil
#endif
}
var supportsGentleScheduledUpdateReminders: Bool {
return true
@@ -167,11 +174,24 @@ extension SparkleAppUpdateService {
func allowedChannels(for updater: SPUUpdater) -> Set<String> {
#if HAS_QA_FEATURES
updateChannel.map { [$0] } ?? []
#else
// we only allow the stable channel in non-QA builds
[AppUpdateChannel.stable.rawValue]
// ensure no update happens for UI tests
if Constants.isInUITests { return [] }
// In QA builds, use the selected channels from settings
// If no channels are selected in the QA settings, default to the usual logic
guard qaOverrideUpdateChannels.isEmpty else {
return Set(qaOverrideUpdateChannels.map(\.rawValue))
}
#endif
// Always include the stable channel
var channels: Set<String> = [AppUpdateChannel.stable.rawValue]
// If the gradual rollout feature flag is enabled, also include the gradual rollout channel
if gradualRolloutEnabled() {
channels.insert(AppUpdateChannel.gradualRollout.rawValue)
}
return channels
}
func updaterShouldRelaunchApplication(_ updater: SPUUpdater) -> Bool {
@@ -163,7 +163,11 @@ class ApplicationState: ObservableObject {
return .fullResyncInProgress
}
if accountInfo == nil {
return .signedOut
if isUpdateAvailable {
return .signedOutAndUpdateAvailable
} else {
return .signedOut
}
}
if isPaused {
return .paused
@@ -42,6 +42,7 @@ enum ApplicationSyncStatus: Sendable, Equatable {
case syncing
// Source: AppUpdateService
case updateAvailable
case signedOutAndUpdateAvailable
// Source: FileProvider
case errored(Int)
// Source: FileProvider
@@ -53,7 +54,7 @@ enum ApplicationSyncStatus: Sendable, Equatable {
var displayLabel: String {
switch self {
case .launching: Localization.menu_status_sync_launching
case .signedOut: Localization.menu_status_signed_out
case .signedOut, .signedOutAndUpdateAvailable: Localization.menu_status_signed_out
case .paused: Localization.menu_status_sync_paused
case .offline: Localization.menu_status_offline
case .enumerating(let itemEnumerationDescription): itemEnumerationDescription
@@ -163,10 +163,9 @@ final class MainWindowCoordinator: NSObject, NSWindowDelegate {
let idealX = buttonRect.midX - MainWindow.size.width / 2
let requiredX = screenFrame.origin.x + screenFrame.width - MainWindow.size.width
let requiredY = screenFrame.origin.y + screenFrame.height - MainWindow.size.height
window.setFrameOrigin(NSPoint(
x: min(idealX, requiredX),
y: requiredY
))
let wouldBeOffScreenOnLeft = idealX < 0
let x = wouldBeOffScreenOnLeft ? requiredX : min(idealX, requiredX)
window.setFrameOrigin(NSPoint(x: x, y: requiredY))
}
window?.makeKeyAndOrderFront(nil)
if #available(macOS 14.0, *) {
@@ -67,6 +67,7 @@ struct SyncStateView: View {
.tint(ColorProvider.SignalDanger)
case .synced,
.signedOut,
.signedOutAndUpdateAvailable,
.updateAvailable,
.fullResyncCompleted:
Image("synced")
@@ -104,6 +104,8 @@ final class MenuBarCoordinator: NSObject, ObservableObject, NSMenuDelegate {
switch status {
case .signedOut:
return "status-signed-out"
case .signedOutAndUpdateAvailable:
return "status-signed-out-update-available"
case .paused:
return "status-paused"
case .offline:
@@ -115,7 +117,7 @@ final class MenuBarCoordinator: NSObject, ObservableObject, NSMenuDelegate {
case .updateAvailable:
return "status-update-available"
case .synced, .fullResyncCompleted:
if state.visibleCampaign != nil && !state.items.isEmpty {
if let campaign = state.visibleCampaign, campaign.displaysOnStatusBar, !state.items.isEmpty {
return "status-promo"
} else {
return "status-synced"
@@ -45,7 +45,8 @@ class DeleteAlerter {
guard deleteAlertShown != true else { return }
let alert = NSAlert()
alert.messageText = "Deleted cloud-only files can be recovered from Trash on the web"
alert.icon = NSApp.applicationIconImage
alert.messageText = "Deleted cloud-only files can be recovered from Proton Drive Trash on the web"
alert.informativeText = "Files deleted from your Mac that are cloud-only will be permanently removed from your computer but can still be restored from Proton Drive Trash on the web. Files stored locally will be moved to your Mac's Trash."
alert.addButton(withTitle: "OK")
+6
View File
@@ -1,4 +1,10 @@
<div>
<h1>2.10.2</h1>
<p>
- Improves update behavior for future releases<br>
</p>
<h1>2.10.1</h1>
<p>