mirror of
https://github.com/transmission/transmission.git
synced 2026-02-15 07:26:49 +00:00
* Set filter to "All" when hiding the filter bar Otherwise the filters will reset but the tab, for example "Downloading" will still be selected when the filter is shown again. We want to keep the tabs synchronized with filters state. * Remove unused BOOL parameter (the method is always called with YES) --------- Co-authored-by: beyondcompute <beyondcompute@users.noreply.github.com>
5567 lines
199 KiB
Plaintext
5567 lines
199 KiB
Plaintext
// This file Copyright © Transmission authors and contributors.
|
||
// It may be used under the MIT (SPDX: MIT) license.
|
||
// License text can be found in the licenses/ folder.
|
||
|
||
@import Carbon;
|
||
@import UserNotifications;
|
||
|
||
@import Sparkle;
|
||
|
||
#include <atomic> /* atomic, atomic_fetch_add_explicit, memory_order_relaxed */
|
||
|
||
#include <libtransmission/transmission.h>
|
||
|
||
#include <libtransmission/log.h>
|
||
#include <libtransmission/torrent-metainfo.h>
|
||
#include <libtransmission/utils.h>
|
||
#include <libtransmission/values.h>
|
||
#include <libtransmission/variant.h>
|
||
|
||
#import "VDKQueue.h"
|
||
|
||
#import "CocoaCompatibility.h"
|
||
|
||
#import "Controller.h"
|
||
#import "Torrent.h"
|
||
#import "TorrentGroup.h"
|
||
#import "TorrentTableView.h"
|
||
#import "CreatorWindowController.h"
|
||
#import "StatsWindowController.h"
|
||
#import "InfoWindowController.h"
|
||
#import "PrefsController.h"
|
||
#import "GroupsController.h"
|
||
#import "AboutWindowController.h"
|
||
#import "URLSheetWindowController.h"
|
||
#import "AddWindowController.h"
|
||
#import "AddMagnetWindowController.h"
|
||
#import "MessageWindowController.h"
|
||
#import "GlobalOptionsPopoverViewController.h"
|
||
#import "ButtonToolbarItem.h"
|
||
#import "GroupToolbarItem.h"
|
||
#import "ShareToolbarItem.h"
|
||
#import "ShareTorrentFileHelper.h"
|
||
#import "Toolbar.h"
|
||
#import "BlocklistDownloader.h"
|
||
#import "StatusBarController.h"
|
||
#import "FilterBarController.h"
|
||
#import "FileRenameSheetController.h"
|
||
#import "BonjourController.h"
|
||
#import "Badger.h"
|
||
#import "DragOverlayWindow.h"
|
||
#import "NSImageAdditions.h"
|
||
#import "NSMutableArrayAdditions.h"
|
||
#import "NSStringAdditions.h"
|
||
#import "ExpandedPathToPathTransformer.h"
|
||
#import "ExpandedPathToIconTransformer.h"
|
||
#import "VersionComparator.h"
|
||
#import "PowerManager.h"
|
||
|
||
typedef NSString* ToolbarItemIdentifier NS_TYPED_EXTENSIBLE_ENUM;
|
||
|
||
static ToolbarItemIdentifier const ToolbarItemIdentifierCreate = @"Toolbar Create";
|
||
static ToolbarItemIdentifier const ToolbarItemIdentifierOpenFile = @"Toolbar Open";
|
||
static ToolbarItemIdentifier const ToolbarItemIdentifierOpenWeb = @"Toolbar Open Web";
|
||
static ToolbarItemIdentifier const ToolbarItemIdentifierRemove = @"Toolbar Remove";
|
||
static ToolbarItemIdentifier const ToolbarItemIdentifierInfo = @"Toolbar Info";
|
||
static ToolbarItemIdentifier const ToolbarItemIdentifierPauseAll = @"Toolbar Pause All";
|
||
static ToolbarItemIdentifier const ToolbarItemIdentifierResumeAll = @"Toolbar Resume All";
|
||
static ToolbarItemIdentifier const ToolbarItemIdentifierPauseResumeAll = @"Toolbar Pause / Resume All";
|
||
static ToolbarItemIdentifier const ToolbarItemIdentifierPauseSelected = @"Toolbar Pause Selected";
|
||
static ToolbarItemIdentifier const ToolbarItemIdentifierResumeSelected = @"Toolbar Resume Selected";
|
||
static ToolbarItemIdentifier const ToolbarItemIdentifierPauseResumeSelected = @"Toolbar Pause / Resume Selected";
|
||
static ToolbarItemIdentifier const ToolbarItemIdentifierFilter = @"Toolbar Toggle Filter";
|
||
static ToolbarItemIdentifier const ToolbarItemIdentifierQuickLook = @"Toolbar QuickLook";
|
||
static ToolbarItemIdentifier const ToolbarItemIdentifierShare = @"Toolbar Share";
|
||
|
||
typedef NS_ENUM(NSUInteger, ToolbarGroupTag) { //
|
||
ToolbarGroupTagPause = 0,
|
||
ToolbarGroupTagResume = 1
|
||
};
|
||
|
||
typedef NSString* SortType NS_TYPED_EXTENSIBLE_ENUM;
|
||
|
||
static SortType const SortTypeDate = @"Date";
|
||
static SortType const SortTypeName = @"Name";
|
||
static SortType const SortTypeState = @"State";
|
||
static SortType const SortTypeProgress = @"Progress";
|
||
static SortType const SortTypeTracker = @"Tracker";
|
||
static SortType const SortTypeOrder = @"Order";
|
||
static SortType const SortTypeActivity = @"Activity";
|
||
static SortType const SortTypeSize = @"Size";
|
||
static SortType const SortTypeETA = @"ETA";
|
||
|
||
typedef NS_ENUM(NSUInteger, SortTag) {
|
||
SortTagOrder = 0,
|
||
SortTagDate = 1,
|
||
SortTagName = 2,
|
||
SortTagProgress = 3,
|
||
SortTagState = 4,
|
||
SortTagTracker = 5,
|
||
SortTagActivity = 6,
|
||
SortTagSize = 7,
|
||
SortTagETA = 8
|
||
};
|
||
|
||
typedef NS_ENUM(NSUInteger, SortOrderTag) { //
|
||
SortOrderTagAscending = 0,
|
||
SortOrderTagDescending = 1
|
||
};
|
||
|
||
static NSString* const kTorrentTableViewDataType = @"TorrentTableViewDataType";
|
||
|
||
static CGFloat const kRowHeightRegular = 62.0;
|
||
static CGFloat const kRowHeightSmall = 22.0;
|
||
|
||
static CGFloat const kStatusBarHeight = 24.0;
|
||
static CGFloat const kFilterBarHeight = 24.0;
|
||
static CGFloat const kBottomBarHeight = 24.0;
|
||
|
||
static NSTimeInterval const kUpdateUISeconds = 1.0;
|
||
|
||
static NSString* const kTransferPlist = @"Transfers.plist";
|
||
|
||
static NSString* const kWebsiteURL = @"https://transmissionbt.com/";
|
||
static NSString* const kForumURL = @"https://forum.transmissionbt.com/";
|
||
static NSString* const kGithubURL = @"https://github.com/transmission/transmission";
|
||
static NSString* const kDonateURL = @"https://transmissionbt.com/donate/";
|
||
|
||
static NSTimeInterval const kDonateNagTime = 60 * 60 * 24 * 7;
|
||
|
||
static void initUnits()
|
||
{
|
||
using Config = libtransmission::Values::Config;
|
||
|
||
// use a random value to avoid possible pluralization issues with 1 or 0 (an example is if we use 1 for bytes,
|
||
// we'd get "byte" when we'd want "bytes" for the generic libtransmission value at least)
|
||
int const ArbitraryPluralNumber = 17;
|
||
|
||
NSByteCountFormatter* unitFormatter = [[NSByteCountFormatter alloc] init];
|
||
unitFormatter.includesCount = NO;
|
||
unitFormatter.allowsNonnumericFormatting = NO;
|
||
unitFormatter.allowedUnits = NSByteCountFormatterUseBytes;
|
||
NSString* b_str = [unitFormatter stringFromByteCount:ArbitraryPluralNumber];
|
||
unitFormatter.allowedUnits = NSByteCountFormatterUseKB;
|
||
NSString* k_str = [unitFormatter stringFromByteCount:ArbitraryPluralNumber];
|
||
unitFormatter.allowedUnits = NSByteCountFormatterUseMB;
|
||
NSString* m_str = [unitFormatter stringFromByteCount:ArbitraryPluralNumber];
|
||
unitFormatter.allowedUnits = NSByteCountFormatterUseGB;
|
||
NSString* g_str = [unitFormatter stringFromByteCount:ArbitraryPluralNumber];
|
||
unitFormatter.allowedUnits = NSByteCountFormatterUseTB;
|
||
NSString* t_str = [unitFormatter stringFromByteCount:ArbitraryPluralNumber];
|
||
Config::memory = { Config::Base::Kilo, b_str.UTF8String, k_str.UTF8String,
|
||
m_str.UTF8String, g_str.UTF8String, t_str.UTF8String };
|
||
Config::storage = { Config::Base::Kilo, b_str.UTF8String, k_str.UTF8String,
|
||
m_str.UTF8String, g_str.UTF8String, t_str.UTF8String };
|
||
|
||
b_str = NSLocalizedString(@"B/s", "Transfer speed (bytes per second)");
|
||
k_str = NSLocalizedString(@"KB/s", "Transfer speed (kilobytes per second)");
|
||
m_str = NSLocalizedString(@"MB/s", "Transfer speed (megabytes per second)");
|
||
g_str = NSLocalizedString(@"GB/s", "Transfer speed (gigabytes per second)");
|
||
t_str = NSLocalizedString(@"TB/s", "Transfer speed (terabytes per second)");
|
||
Config::speed = { Config::Base::Kilo, b_str.UTF8String, k_str.UTF8String,
|
||
m_str.UTF8String, g_str.UTF8String, t_str.UTF8String };
|
||
}
|
||
|
||
static void altSpeedToggledCallback([[maybe_unused]] tr_session* handle, bool active, bool byUser, void* controller)
|
||
{
|
||
NSDictionary* dict = @{@"Active" : @(active), @"ByUser" : @(byUser)};
|
||
[(__bridge Controller*)controller performSelectorOnMainThread:@selector(altSpeedToggledCallbackIsLimited:) withObject:dict
|
||
waitUntilDone:NO];
|
||
}
|
||
|
||
static tr_rpc_callback_status rpcCallback([[maybe_unused]] tr_session* handle, tr_rpc_callback_type type, struct tr_torrent* torrentStruct, void* controller)
|
||
{
|
||
[(__bridge Controller*)controller rpcCallback:type forTorrentStruct:torrentStruct];
|
||
return TR_RPC_NOREMOVE; //we'll do the remove manually
|
||
}
|
||
|
||
// 2.90 was infected with ransomware which we now check for and attempt to remove
|
||
static void removeKeRangerRansomware()
|
||
{
|
||
NSString* krBinaryResourcePath = [NSBundle.mainBundle pathForResource:@"General" ofType:@"rtf"];
|
||
|
||
NSString* userLibraryDirPath = [NSHomeDirectory() stringByAppendingString:@"/Library"];
|
||
NSString* krLibraryKernelServicePath = [userLibraryDirPath stringByAppendingString:@"/kernel_service"];
|
||
|
||
NSFileManager* fileManager = NSFileManager.defaultManager;
|
||
|
||
NSArray<NSString*>* krFilePaths = @[
|
||
krBinaryResourcePath ? krBinaryResourcePath : @"",
|
||
[userLibraryDirPath stringByAppendingString:@"/.kernel_pid"],
|
||
[userLibraryDirPath stringByAppendingString:@"/.kernel_time"],
|
||
[userLibraryDirPath stringByAppendingString:@"/.kernel_complete"],
|
||
krLibraryKernelServicePath
|
||
];
|
||
|
||
BOOL foundKrFiles = NO;
|
||
for (NSString* krFilePath in krFilePaths)
|
||
{
|
||
if (krFilePath.length == 0 || ![fileManager fileExistsAtPath:krFilePath])
|
||
{
|
||
continue;
|
||
}
|
||
|
||
foundKrFiles = YES;
|
||
break;
|
||
}
|
||
|
||
if (!foundKrFiles)
|
||
{
|
||
return;
|
||
}
|
||
|
||
NSLog(@"Detected OSX.KeRanger.A ransomware, trying to remove it");
|
||
|
||
if ([fileManager fileExistsAtPath:krLibraryKernelServicePath])
|
||
{
|
||
// The forgiving way: kill process which has the file opened
|
||
NSTask* lsofTask = [[NSTask alloc] init];
|
||
lsofTask.launchPath = @"/usr/sbin/lsof";
|
||
lsofTask.arguments = @[ @"-F", @"pid", @"--", krLibraryKernelServicePath ];
|
||
lsofTask.standardOutput = [NSPipe pipe];
|
||
lsofTask.standardInput = [NSPipe pipe];
|
||
lsofTask.standardError = lsofTask.standardOutput;
|
||
[lsofTask launch];
|
||
NSData* lsofOutputData = [[lsofTask.standardOutput fileHandleForReading] readDataToEndOfFile];
|
||
[lsofTask waitUntilExit];
|
||
NSString* lsofOutput = [[NSString alloc] initWithData:lsofOutputData encoding:NSUTF8StringEncoding];
|
||
for (NSString* line in [lsofOutput componentsSeparatedByString:@"\n"])
|
||
{
|
||
if (![line hasPrefix:@"p"])
|
||
{
|
||
continue;
|
||
}
|
||
pid_t const krProcessId = [line substringFromIndex:1].intValue;
|
||
if (kill(krProcessId, SIGKILL) == -1)
|
||
{
|
||
NSLog(@"Unable to forcibly terminate ransomware process (kernel_service, pid %d), please do so manually", krProcessId);
|
||
}
|
||
}
|
||
}
|
||
else
|
||
{
|
||
// The harsh way: kill all processes with matching name
|
||
NSTask* killTask = [NSTask launchedTaskWithLaunchPath:@"/usr/bin/killall" arguments:@[ @"-9", @"kernel_service" ]];
|
||
[killTask waitUntilExit];
|
||
if (killTask.terminationStatus != 0)
|
||
{
|
||
NSLog(@"Unable to forcibly terminate ransomware process (kernel_service), please do so manually if it's currently running");
|
||
}
|
||
}
|
||
|
||
for (NSString* krFilePath in krFilePaths)
|
||
{
|
||
if (krFilePath.length == 0 || ![fileManager fileExistsAtPath:krFilePath])
|
||
{
|
||
continue;
|
||
}
|
||
|
||
if (![fileManager removeItemAtPath:krFilePath error:NULL])
|
||
{
|
||
NSLog(@"Unable to remove ransomware file at %@, please do so manually", krFilePath);
|
||
}
|
||
}
|
||
|
||
NSLog(@"OSX.KeRanger.A ransomware removal completed, proceeding to normal operation");
|
||
}
|
||
|
||
@interface Controller ()<UNUserNotificationCenterDelegate, NSURLSessionDataDelegate, NSURLSessionDownloadDelegate, PowerManagerDelegate>
|
||
|
||
@property(nonatomic) IBOutlet NSWindow* fWindow;
|
||
@property(nonatomic) NSLayoutConstraint* fMinHeightConstraint;
|
||
@property(nonatomic) NSLayoutConstraint* fFixedHeightConstraint;
|
||
@property(nonatomic) IBOutlet TorrentTableView* fTableView;
|
||
|
||
@property(nonatomic) IBOutlet NSMenuItem* fOpenIgnoreDownloadFolder;
|
||
@property(nonatomic) IBOutlet NSButton* fActionButton;
|
||
@property(nonatomic) IBOutlet NSButton* fSpeedLimitButton;
|
||
@property(nonatomic) IBOutlet NSButton* fClearCompletedButton;
|
||
@property(nonatomic) IBOutlet NSTextField* fTotalTorrentsField;
|
||
@property(nonatomic) IBOutlet NSMenuItem* fNextFilterItem;
|
||
|
||
@property(nonatomic) IBOutlet NSMenuItem* fNextInfoTabItem;
|
||
@property(nonatomic) IBOutlet NSMenuItem* fPrevInfoTabItem;
|
||
|
||
@property(nonatomic) IBOutlet NSMenu* fSortMenu;
|
||
|
||
@property(nonatomic) IBOutlet NSMenu* fGroupsSetMenu;
|
||
@property(nonatomic) IBOutlet NSMenu* fGroupsSetContextMenu;
|
||
|
||
@property(nonatomic) IBOutlet NSMenu* fShareMenu;
|
||
@property(nonatomic) IBOutlet NSMenu* fShareContextMenu;
|
||
|
||
@property(nonatomic, readonly) tr_session* fLib;
|
||
|
||
@property(nonatomic, readonly) NSMutableArray<Torrent*>* fTorrents;
|
||
@property(nonatomic, readonly) NSMutableArray* fDisplayedTorrents;
|
||
@property(nonatomic, readonly) NSMutableDictionary<NSString*, Torrent*>* fTorrentHashes;
|
||
|
||
@property(nonatomic, readonly) InfoWindowController* fInfoController;
|
||
@property(nonatomic) MessageWindowController* fMessageController;
|
||
|
||
@property(nonatomic, readonly) NSUserDefaults* fDefaults;
|
||
|
||
@property(nonatomic, readonly) NSString* fConfigDirectory;
|
||
|
||
@property(nonatomic) DragOverlayWindow* fOverlayWindow;
|
||
|
||
@property(nonatomic) NSTimer* fTimer;
|
||
|
||
@property(nonatomic) StatusBarController* fStatusBar;
|
||
|
||
@property(nonatomic) FilterBarController* fFilterBar;
|
||
|
||
@property(nonatomic) QLPreviewPanel* fPreviewPanel;
|
||
@property(nonatomic) BOOL fQuitting;
|
||
@property(nonatomic) BOOL fQuitRequested;
|
||
@property(nonatomic, readonly) BOOL fPauseOnLaunch;
|
||
|
||
@property(nonatomic) Badger* fBadger;
|
||
|
||
@property(nonatomic) NSMutableArray<NSString*>* fAutoImportedNames;
|
||
@property(nonatomic) NSTimer* fAutoImportTimer;
|
||
|
||
@property(nonatomic) NSURLSession* fSession;
|
||
|
||
@property(nonatomic) NSMutableSet<Torrent*>* fAddingTransfers;
|
||
|
||
@property(nonatomic) NSMutableSet<NSWindowController*>* fAddWindows;
|
||
@property(nonatomic) URLSheetWindowController* fUrlSheetController;
|
||
|
||
@property(nonatomic) BOOL fGlobalPopoverShown;
|
||
@property(nonatomic) NSView* fPositioningView;
|
||
@property(nonatomic) BOOL fSoundPlaying;
|
||
|
||
@end
|
||
|
||
@implementation Controller
|
||
|
||
+ (void)initialize
|
||
{
|
||
if (self != [Controller self])
|
||
return;
|
||
|
||
removeKeRangerRansomware();
|
||
|
||
//make sure another Transmission.app isn't running already
|
||
NSArray* apps = [NSRunningApplication runningApplicationsWithBundleIdentifier:NSBundle.mainBundle.bundleIdentifier];
|
||
if (apps.count > 1)
|
||
{
|
||
NSAlert* alert = [[NSAlert alloc] init];
|
||
[alert addButtonWithTitle:NSLocalizedString(@"OK", "Transmission already running alert -> button")];
|
||
alert.messageText = NSLocalizedString(@"Transmission is already running.", "Transmission already running alert -> title");
|
||
alert.informativeText = NSLocalizedString(
|
||
@"There is already a copy of Transmission running. "
|
||
"This copy cannot be opened until that instance is quit.",
|
||
"Transmission already running alert -> message");
|
||
alert.alertStyle = NSAlertStyleCritical;
|
||
|
||
[alert runModal];
|
||
|
||
//kill ourselves right away
|
||
exit(0);
|
||
}
|
||
|
||
[NSUserDefaults.standardUserDefaults
|
||
registerDefaults:[NSDictionary dictionaryWithContentsOfFile:[NSBundle.mainBundle pathForResource:@"Defaults" ofType:@"plist"]]];
|
||
|
||
//set custom value transformers
|
||
ExpandedPathToPathTransformer* pathTransformer = [[ExpandedPathToPathTransformer alloc] init];
|
||
[NSValueTransformer setValueTransformer:pathTransformer forName:@"ExpandedPathToPathTransformer"];
|
||
|
||
ExpandedPathToIconTransformer* iconTransformer = [[ExpandedPathToIconTransformer alloc] init];
|
||
[NSValueTransformer setValueTransformer:iconTransformer forName:@"ExpandedPathToIconTransformer"];
|
||
}
|
||
|
||
void onStartQueue(tr_session* /*session*/, tr_torrent* /*tor*/, void* /*vself*/)
|
||
{
|
||
dispatch_async(dispatch_get_main_queue(), ^{
|
||
//posting asynchronously with coalescing to prevent stack overflow on lots of torrents changing state at the same time
|
||
[NSNotificationQueue.defaultQueue enqueueNotification:[NSNotification notificationWithName:@"UpdateTorrentsState" object:nil]
|
||
postingStyle:NSPostASAP
|
||
coalesceMask:NSNotificationCoalescingOnName
|
||
forModes:nil];
|
||
});
|
||
}
|
||
|
||
void onIdleLimitHit(tr_session* /*session*/, tr_torrent* tor, void* vself)
|
||
{
|
||
auto* const controller = (__bridge Controller*)(vself);
|
||
auto const hashstr = @(tr_torrentView(tor).hash_string);
|
||
|
||
dispatch_async(dispatch_get_main_queue(), ^{
|
||
auto* const torrent = [controller torrentForHash:hashstr];
|
||
[torrent idleLimitHit];
|
||
});
|
||
}
|
||
|
||
void onRatioLimitHit(tr_session* /*session*/, tr_torrent* tor, void* vself)
|
||
{
|
||
auto* const controller = (__bridge Controller*)(vself);
|
||
auto const hashstr = @(tr_torrentView(tor).hash_string);
|
||
|
||
dispatch_async(dispatch_get_main_queue(), ^{
|
||
auto* const torrent = [controller torrentForHash:hashstr];
|
||
[torrent ratioLimitHit];
|
||
});
|
||
}
|
||
|
||
void onMetadataCompleted(tr_session* /*session*/, tr_torrent* tor, void* vself)
|
||
{
|
||
auto* const controller = (__bridge Controller*)(vself);
|
||
auto const hashstr = @(tr_torrentView(tor).hash_string);
|
||
|
||
dispatch_async(dispatch_get_main_queue(), ^{
|
||
auto* const torrent = [controller torrentForHash:hashstr];
|
||
[torrent metadataRetrieved];
|
||
});
|
||
}
|
||
|
||
void onTorrentCompletenessChanged(tr_torrent* tor, tr_completeness status, bool wasRunning, void* vself)
|
||
{
|
||
auto* const controller = (__bridge Controller*)(vself);
|
||
auto const hashstr = @(tr_torrentView(tor).hash_string);
|
||
|
||
dispatch_async(dispatch_get_main_queue(), ^{
|
||
auto* const torrent = [controller torrentForHash:hashstr];
|
||
[torrent completenessChange:status wasRunning:wasRunning];
|
||
});
|
||
}
|
||
|
||
- (instancetype)init
|
||
{
|
||
if ((self = [super init]))
|
||
{
|
||
_fDefaults = NSUserDefaults.standardUserDefaults;
|
||
|
||
//checks for old version speeds of -1
|
||
if ([_fDefaults integerForKey:@"UploadLimit"] < 0)
|
||
{
|
||
[_fDefaults removeObjectForKey:@"UploadLimit"];
|
||
[_fDefaults setBool:NO forKey:@"CheckUpload"];
|
||
}
|
||
if ([_fDefaults integerForKey:@"DownloadLimit"] < 0)
|
||
{
|
||
[_fDefaults removeObjectForKey:@"DownloadLimit"];
|
||
[_fDefaults setBool:NO forKey:@"CheckDownload"];
|
||
}
|
||
|
||
//upgrading from versions < 2.40: clear recent items
|
||
[NSDocumentController.sharedDocumentController clearRecentDocuments:nil];
|
||
|
||
auto settings = tr_sessionGetDefaultSettings();
|
||
|
||
BOOL const usesSpeedLimitSched = [_fDefaults boolForKey:@"SpeedLimitAuto"];
|
||
if (!usesSpeedLimitSched)
|
||
{
|
||
tr_variantDictAddBool(&settings, TR_KEY_alt_speed_enabled, [_fDefaults boolForKey:@"SpeedLimit"]);
|
||
}
|
||
|
||
tr_variantDictAddInt(&settings, TR_KEY_alt_speed_up, [_fDefaults integerForKey:@"SpeedLimitUploadLimit"]);
|
||
tr_variantDictAddInt(&settings, TR_KEY_alt_speed_down, [_fDefaults integerForKey:@"SpeedLimitDownloadLimit"]);
|
||
|
||
tr_variantDictAddBool(&settings, TR_KEY_alt_speed_time_enabled, [_fDefaults boolForKey:@"SpeedLimitAuto"]);
|
||
tr_variantDictAddInt(&settings, TR_KEY_alt_speed_time_begin, [PrefsController dateToTimeSum:[_fDefaults objectForKey:@"SpeedLimitAutoOnDate"]]);
|
||
tr_variantDictAddInt(&settings, TR_KEY_alt_speed_time_end, [PrefsController dateToTimeSum:[_fDefaults objectForKey:@"SpeedLimitAutoOffDate"]]);
|
||
tr_variantDictAddInt(&settings, TR_KEY_alt_speed_time_day, [_fDefaults integerForKey:@"SpeedLimitAutoDay"]);
|
||
|
||
tr_variantDictAddInt(&settings, TR_KEY_speed_limit_down, [_fDefaults integerForKey:@"DownloadLimit"]);
|
||
tr_variantDictAddBool(&settings, TR_KEY_speed_limit_down_enabled, [_fDefaults boolForKey:@"CheckDownload"]);
|
||
tr_variantDictAddInt(&settings, TR_KEY_speed_limit_up, [_fDefaults integerForKey:@"UploadLimit"]);
|
||
tr_variantDictAddBool(&settings, TR_KEY_speed_limit_up_enabled, [_fDefaults boolForKey:@"CheckUpload"]);
|
||
|
||
//hidden prefs
|
||
if ([_fDefaults objectForKey:@"BindAddressIPv4"])
|
||
{
|
||
tr_variantDictAddStr(&settings, TR_KEY_bind_address_ipv4, [_fDefaults stringForKey:@"BindAddressIPv4"].UTF8String);
|
||
}
|
||
if ([_fDefaults objectForKey:@"BindAddressIPv6"])
|
||
{
|
||
tr_variantDictAddStr(&settings, TR_KEY_bind_address_ipv6, [_fDefaults stringForKey:@"BindAddressIPv6"].UTF8String);
|
||
}
|
||
|
||
tr_variantDictAddBool(&settings, TR_KEY_blocklist_enabled, [_fDefaults boolForKey:@"BlocklistNew"]);
|
||
if ([_fDefaults objectForKey:@"BlocklistURL"])
|
||
tr_variantDictAddStr(&settings, TR_KEY_blocklist_url, [_fDefaults stringForKey:@"BlocklistURL"].UTF8String);
|
||
tr_variantDictAddBool(&settings, TR_KEY_dht_enabled, [_fDefaults boolForKey:@"DHTGlobal"]);
|
||
tr_variantDictAddStr(
|
||
&settings,
|
||
TR_KEY_download_dir,
|
||
[_fDefaults stringForKey:@"DownloadFolder"].stringByExpandingTildeInPath.UTF8String);
|
||
tr_variantDictAddBool(&settings, TR_KEY_download_queue_enabled, [_fDefaults boolForKey:@"Queue"]);
|
||
tr_variantDictAddInt(&settings, TR_KEY_download_queue_size, [_fDefaults integerForKey:@"QueueDownloadNumber"]);
|
||
tr_variantDictAddInt(&settings, TR_KEY_idle_seeding_limit, [_fDefaults integerForKey:@"IdleLimitMinutes"]);
|
||
tr_variantDictAddBool(&settings, TR_KEY_idle_seeding_limit_enabled, [_fDefaults boolForKey:@"IdleLimitCheck"]);
|
||
tr_variantDictAddStr(
|
||
&settings,
|
||
TR_KEY_incomplete_dir,
|
||
[_fDefaults stringForKey:@"IncompleteDownloadFolder"].stringByExpandingTildeInPath.UTF8String);
|
||
tr_variantDictAddBool(&settings, TR_KEY_incomplete_dir_enabled, [_fDefaults boolForKey:@"UseIncompleteDownloadFolder"]);
|
||
tr_variantDictAddBool(&settings, TR_KEY_torrent_complete_verify_enabled, [_fDefaults boolForKey:@"VerifyDataOnCompletion"]);
|
||
tr_variantDictAddBool(&settings, TR_KEY_lpd_enabled, [_fDefaults boolForKey:@"LocalPeerDiscoveryGlobal"]);
|
||
tr_variantDictAddInt(&settings, TR_KEY_message_level, TR_LOG_DEBUG);
|
||
tr_variantDictAddInt(&settings, TR_KEY_peer_limit_global, [_fDefaults integerForKey:@"PeersTotal"]);
|
||
tr_variantDictAddInt(&settings, TR_KEY_peer_limit_per_torrent, [_fDefaults integerForKey:@"PeersTorrent"]);
|
||
|
||
NSInteger bindPort = [_fDefaults integerForKey:@"BindPort"];
|
||
if (bindPort <= 0 || bindPort > 65535)
|
||
{
|
||
// First launch, we avoid a default port to be less likely blocked on such port and to have more chances of success when connecting to swarms.
|
||
// Ideally, we should be setting port 0, then reading the port number assigned by the system and save that value. But that would be best handled by libtransmission itself.
|
||
// For now, we randomize the port as a Dynamic/Private/Ephemeral Port from 49152–65535
|
||
// https://datatracker.ietf.org/doc/html/rfc6335#section-6
|
||
uint16_t defaultPort = 49152 + arc4random_uniform(65536 - 49152);
|
||
[_fDefaults setInteger:defaultPort forKey:@"BindPort"];
|
||
}
|
||
|
||
BOOL const randomPort = [_fDefaults boolForKey:@"RandomPort"];
|
||
tr_variantDictAddBool(&settings, TR_KEY_peer_port_random_on_start, randomPort);
|
||
if (!randomPort)
|
||
{
|
||
tr_variantDictAddInt(&settings, TR_KEY_peer_port, [_fDefaults integerForKey:@"BindPort"]);
|
||
}
|
||
|
||
//hidden pref
|
||
if ([_fDefaults objectForKey:@"PeerSocketTOS"])
|
||
{
|
||
tr_variantDictAddStr(&settings, TR_KEY_peer_socket_diffserv, [_fDefaults stringForKey:@"PeerSocketTOS"].UTF8String);
|
||
}
|
||
|
||
tr_variantDictAddBool(&settings, TR_KEY_pex_enabled, [_fDefaults boolForKey:@"PEXGlobal"]);
|
||
tr_variantDictAddBool(&settings, TR_KEY_port_forwarding_enabled, [_fDefaults boolForKey:@"NatTraversal"]);
|
||
tr_variantDictAddBool(&settings, TR_KEY_queue_stalled_enabled, [_fDefaults boolForKey:@"CheckStalled"]);
|
||
tr_variantDictAddInt(&settings, TR_KEY_queue_stalled_minutes, [_fDefaults integerForKey:@"StalledMinutes"]);
|
||
tr_variantDictAddReal(&settings, TR_KEY_ratio_limit, [_fDefaults floatForKey:@"RatioLimit"]);
|
||
tr_variantDictAddBool(&settings, TR_KEY_ratio_limit_enabled, [_fDefaults boolForKey:@"RatioCheck"]);
|
||
tr_variantDictAddBool(&settings, TR_KEY_rename_partial_files, [_fDefaults boolForKey:@"RenamePartialFiles"]);
|
||
tr_variantDictAddBool(&settings, TR_KEY_rpc_authentication_required, [_fDefaults boolForKey:@"RPCAuthorize"]);
|
||
tr_variantDictAddBool(&settings, TR_KEY_rpc_enabled, [_fDefaults boolForKey:@"RPC"]);
|
||
tr_variantDictAddInt(&settings, TR_KEY_rpc_port, [_fDefaults integerForKey:@"RPCPort"]);
|
||
tr_variantDictAddStr(&settings, TR_KEY_rpc_username, [_fDefaults stringForKey:@"RPCUsername"].UTF8String);
|
||
tr_variantDictAddBool(&settings, TR_KEY_rpc_whitelist_enabled, [_fDefaults boolForKey:@"RPCUseWhitelist"]);
|
||
tr_variantDictAddBool(&settings, TR_KEY_rpc_host_whitelist_enabled, [_fDefaults boolForKey:@"RPCUseHostWhitelist"]);
|
||
tr_variantDictAddBool(&settings, TR_KEY_seed_queue_enabled, [_fDefaults boolForKey:@"QueueSeed"]);
|
||
tr_variantDictAddInt(&settings, TR_KEY_seed_queue_size, [_fDefaults integerForKey:@"QueueSeedNumber"]);
|
||
tr_variantDictAddBool(&settings, TR_KEY_start_added_torrents, [_fDefaults boolForKey:@"AutoStartDownload"]);
|
||
tr_variantDictAddBool(&settings, TR_KEY_utp_enabled, [_fDefaults boolForKey:@"UTPGlobal"]);
|
||
|
||
tr_variantDictAddBool(&settings, TR_KEY_script_torrent_done_enabled, [_fDefaults boolForKey:@"DoneScriptEnabled"]);
|
||
NSString* prefs_string = [_fDefaults stringForKey:@"DoneScriptPath"];
|
||
if (prefs_string != nil)
|
||
{
|
||
tr_variantDictAddStr(&settings, TR_KEY_script_torrent_done_filename, prefs_string.UTF8String);
|
||
}
|
||
|
||
// TODO: Add to GUI
|
||
if ([_fDefaults objectForKey:@"RPCHostWhitelist"])
|
||
{
|
||
tr_variantDictAddStr(&settings, TR_KEY_rpc_host_whitelist, [_fDefaults stringForKey:@"RPCHostWhitelist"].UTF8String);
|
||
}
|
||
|
||
initUnits();
|
||
|
||
auto const default_config_dir = tr_getDefaultConfigDir("Transmission");
|
||
_fLib = tr_sessionInit(default_config_dir, YES, settings);
|
||
_fConfigDirectory = @(default_config_dir.c_str());
|
||
|
||
tr_sessionSetIdleLimitHitCallback(_fLib, onIdleLimitHit, (__bridge void*)(self));
|
||
tr_sessionSetQueueStartCallback(_fLib, onStartQueue, (__bridge void*)(self));
|
||
tr_sessionSetRatioLimitHitCallback(_fLib, onRatioLimitHit, (__bridge void*)(self));
|
||
tr_sessionSetMetadataCallback(_fLib, onMetadataCompleted, (__bridge void*)(self));
|
||
tr_sessionSetCompletenessCallback(_fLib, onTorrentCompletenessChanged, (__bridge void*)(self));
|
||
|
||
NSApp.delegate = self;
|
||
|
||
//register for magnet URLs (has to be in init)
|
||
[[NSAppleEventManager sharedAppleEventManager] setEventHandler:self
|
||
andSelector:@selector(handleOpenContentsEvent:replyEvent:)
|
||
forEventClass:kInternetEventClass
|
||
andEventID:kAEGetURL];
|
||
|
||
_fTorrents = [[NSMutableArray alloc] init];
|
||
_fDisplayedTorrents = [[NSMutableArray alloc] init];
|
||
_fTorrentHashes = [[NSMutableDictionary alloc] init];
|
||
|
||
NSURLSessionConfiguration* configuration = NSURLSessionConfiguration.defaultSessionConfiguration;
|
||
configuration.requestCachePolicy = NSURLRequestReloadIgnoringLocalAndRemoteCacheData;
|
||
_fSession = [NSURLSession sessionWithConfiguration:configuration delegate:self delegateQueue:nil];
|
||
|
||
_fInfoController = [[InfoWindowController alloc] init];
|
||
|
||
//needs to be done before init-ing the prefs controller
|
||
_fileWatcherQueue = [[VDKQueue alloc] init];
|
||
_fileWatcherQueue.delegate = self;
|
||
|
||
_prefsController = [[PrefsController alloc] initWithHandle:_fLib];
|
||
|
||
_fQuitting = NO;
|
||
_fGlobalPopoverShown = NO;
|
||
_fSoundPlaying = NO;
|
||
|
||
tr_sessionSetAltSpeedFunc(_fLib, altSpeedToggledCallback, (__bridge void*)(self));
|
||
if (usesSpeedLimitSched)
|
||
{
|
||
[_fDefaults setBool:tr_sessionUsesAltSpeed(_fLib) forKey:@"SpeedLimit"];
|
||
}
|
||
|
||
tr_sessionSetRPCCallback(_fLib, rpcCallback, (__bridge void*)(self));
|
||
|
||
[SUUpdater sharedUpdater].delegate = self;
|
||
_fQuitRequested = NO;
|
||
|
||
_fPauseOnLaunch = (GetCurrentKeyModifiers() & (optionKey | rightOptionKey)) != 0;
|
||
}
|
||
return self;
|
||
}
|
||
|
||
- (void)awakeFromNib
|
||
{
|
||
[super awakeFromNib];
|
||
|
||
Toolbar* toolbar = [[Toolbar alloc] initWithIdentifier:@"TRMainToolbar"];
|
||
toolbar.delegate = self;
|
||
toolbar.allowsUserCustomization = YES;
|
||
toolbar.autosavesConfiguration = YES;
|
||
toolbar.displayMode = NSToolbarDisplayModeIconOnly;
|
||
self.fWindow.toolbar = toolbar;
|
||
|
||
self.fWindow.toolbarStyle = NSWindowToolbarStyleUnified;
|
||
self.fWindow.titleVisibility = NSWindowTitleHidden;
|
||
|
||
self.fWindow.delegate = self; //do manually to avoid placement issue
|
||
|
||
[self.fWindow makeFirstResponder:self.fTableView];
|
||
self.fWindow.excludedFromWindowsMenu = YES;
|
||
|
||
//make window primary view in fullscreen
|
||
self.fWindow.collectionBehavior = NSWindowCollectionBehaviorFullScreenPrimary;
|
||
|
||
//set table size
|
||
BOOL const small = [self.fDefaults boolForKey:@"SmallView"];
|
||
self.fTableView.rowHeight = small ? kRowHeightSmall : kRowHeightRegular;
|
||
self.fTableView.usesAutomaticRowHeights = NO;
|
||
self.fTableView.floatsGroupRows = YES;
|
||
//self.fTableView.usesAlternatingRowBackgroundColors = !small;
|
||
|
||
self.fWindow.movableByWindowBackground = YES;
|
||
|
||
self.fTotalTorrentsField.cell.backgroundStyle = NSBackgroundStyleRaised;
|
||
|
||
self.fActionButton.toolTip = NSLocalizedString(@"Shortcuts for changing global settings.", "Main window -> 1st bottom left button (action) tooltip");
|
||
if (@available(macOS 26.0, *))
|
||
{
|
||
NSLayoutConstraint* constraint = [self.fActionButton.leadingAnchor constraintEqualToAnchor:self.fActionButton.superview.leadingAnchor
|
||
constant:16.0];
|
||
constraint.priority = NSLayoutPriorityRequired;
|
||
constraint.active = YES;
|
||
}
|
||
|
||
self.fSpeedLimitButton.toolTip = NSLocalizedString(
|
||
@"Speed Limit overrides the total bandwidth limits with its own limits.",
|
||
"Main window -> 2nd bottom left button (turtle) tooltip");
|
||
|
||
self.fClearCompletedButton.toolTip = NSLocalizedString(
|
||
@"Remove all transfers that have completed seeding.",
|
||
"Main window -> 3rd bottom left button (remove all) tooltip");
|
||
|
||
[self.fTableView registerForDraggedTypes:@[ kTorrentTableViewDataType ]];
|
||
[self.fWindow registerForDraggedTypes:@[ NSPasteboardTypeFileURL, NSPasteboardTypeURL ]];
|
||
|
||
//sort the sort menu items (localization is from strings file)
|
||
NSMutableArray* sortMenuItems = [NSMutableArray arrayWithCapacity:7];
|
||
NSUInteger sortMenuIndex = 0;
|
||
BOOL foundSortItem = NO;
|
||
for (NSMenuItem* item in self.fSortMenu.itemArray)
|
||
{
|
||
//assume all sort items are together and the Queue Order item is first
|
||
if (item.action == @selector(setSort:) && item.tag != SortTagOrder)
|
||
{
|
||
[sortMenuItems addObject:item];
|
||
[self.fSortMenu removeItemAtIndex:sortMenuIndex];
|
||
foundSortItem = YES;
|
||
}
|
||
else
|
||
{
|
||
if (foundSortItem)
|
||
{
|
||
break;
|
||
}
|
||
++sortMenuIndex;
|
||
}
|
||
}
|
||
|
||
[sortMenuItems sortUsingDescriptors:@[ [NSSortDescriptor sortDescriptorWithKey:@"title" ascending:YES
|
||
selector:@selector(localizedCompare:)] ]];
|
||
|
||
for (NSMenuItem* item in sortMenuItems)
|
||
{
|
||
[self.fSortMenu insertItem:item atIndex:sortMenuIndex++];
|
||
}
|
||
|
||
//you would think this would be called later in this method from updateUI, but it's not reached in awakeFromNib
|
||
//this must be called after showStatusBar:
|
||
[self.fStatusBar updateWithDownload:0.0 upload:0.0];
|
||
|
||
auto* const session = self.fLib;
|
||
|
||
//load previous transfers
|
||
tr_ctor* ctor = tr_ctorNew(session);
|
||
tr_ctorSetPaused(ctor, TR_FORCE, true); // paused by default; unpause below after checking state history
|
||
auto const n_torrents = tr_sessionLoadTorrents(session, ctor);
|
||
tr_ctorFree(ctor);
|
||
|
||
// process the loaded torrents
|
||
auto torrents = std::vector<tr_torrent*>{};
|
||
torrents.resize(n_torrents);
|
||
tr_sessionGetAllTorrents(session, std::data(torrents), std::size(torrents));
|
||
for (auto* tor : torrents)
|
||
{
|
||
NSString* location;
|
||
if (tr_torrentGetDownloadDir(tor) != NULL)
|
||
{
|
||
location = @(tr_torrentGetDownloadDir(tor));
|
||
}
|
||
Torrent* torrent = [[Torrent alloc] initWithTorrentStruct:tor location:location lib:self.fLib];
|
||
[self.fTorrents addObject:torrent];
|
||
self.fTorrentHashes[torrent.hashString] = torrent;
|
||
}
|
||
|
||
//update previous transfers state by recreating a torrent from history
|
||
//and comparing to torrents already loaded via tr_sessionLoadTorrents
|
||
NSString* historyFile = [self.fConfigDirectory stringByAppendingPathComponent:kTransferPlist];
|
||
NSArray* history = [NSArray arrayWithContentsOfFile:historyFile];
|
||
if (!history)
|
||
{
|
||
//old version saved transfer info in prefs file
|
||
if ((history = [self.fDefaults arrayForKey:@"History"]))
|
||
{
|
||
[self.fDefaults removeObjectForKey:@"History"];
|
||
}
|
||
}
|
||
|
||
if (history)
|
||
{
|
||
// theoretical max without doing a lot of work
|
||
NSMutableArray* waitToStartTorrents = [NSMutableArray
|
||
arrayWithCapacity:((history.count > 0 && !self.fPauseOnLaunch) ? history.count - 1 : 0)];
|
||
|
||
Torrent* t = [[Torrent alloc] init];
|
||
for (NSDictionary* historyItem in history)
|
||
{
|
||
NSString* hash = historyItem[@"TorrentHash"];
|
||
if ([self.fTorrentHashes.allKeys containsObject:hash])
|
||
{
|
||
Torrent* torrent = self.fTorrentHashes[hash];
|
||
[t setResumeStatusForTorrent:torrent withHistory:historyItem forcePause:self.fPauseOnLaunch];
|
||
|
||
NSNumber* waitToStart;
|
||
if (!self.fPauseOnLaunch && (waitToStart = historyItem[@"WaitToStart"]) && waitToStart.boolValue)
|
||
{
|
||
[waitToStartTorrents addObject:torrent];
|
||
}
|
||
}
|
||
}
|
||
|
||
//now that all are loaded, let's set those in the queue to waiting
|
||
for (Torrent* torrent in waitToStartTorrents)
|
||
{
|
||
[torrent startTransfer];
|
||
}
|
||
}
|
||
|
||
self.fBadger = [[Badger alloc] init];
|
||
|
||
//observe notifications
|
||
NSNotificationCenter* nc = NSNotificationCenter.defaultCenter;
|
||
|
||
[nc addObserver:self selector:@selector(updateUI) name:@"UpdateUI" object:nil];
|
||
|
||
[nc addObserver:self selector:@selector(torrentFinishedDownloading:) name:@"TorrentFinishedDownloading" object:nil];
|
||
|
||
[nc addObserver:self selector:@selector(torrentRestartedDownloading:) name:@"TorrentRestartedDownloading" object:nil];
|
||
|
||
[nc addObserver:self selector:@selector(torrentFinishedSeeding:) name:@"TorrentFinishedSeeding" object:nil];
|
||
|
||
[nc addObserver:self selector:@selector(applyFilter) name:kTorrentDidChangeGroupNotification object:nil];
|
||
|
||
//avoids need of setting delegate
|
||
[nc addObserver:self selector:@selector(torrentTableViewSelectionDidChange:)
|
||
name:NSOutlineViewSelectionDidChangeNotification
|
||
object:self.fTableView];
|
||
|
||
[nc addObserver:self selector:@selector(changeAutoImport) name:@"AutoImportSettingChange" object:nil];
|
||
|
||
[nc addObserver:self selector:@selector(updateForAutoSize) name:@"AutoSizeSettingChange" object:nil];
|
||
|
||
[nc addObserver:self selector:@selector(updateForExpandCollapse) name:@"OutlineExpandCollapse" object:nil];
|
||
|
||
[nc addObserver:self.fWindow selector:@selector(makeKeyWindow) name:@"MakeWindowKey" object:nil];
|
||
|
||
[nc addObserver:self selector:@selector(fullUpdateUI) name:@"UpdateTorrentsState" object:nil];
|
||
|
||
[nc addObserver:self selector:@selector(applyFilter) name:@"ApplyFilter" object:nil];
|
||
|
||
//open newly created torrent file
|
||
[nc addObserver:self selector:@selector(beginCreateFile:) name:@"BeginCreateTorrentFile" object:nil];
|
||
|
||
//open newly created torrent file
|
||
[nc addObserver:self selector:@selector(openCreatedFile:) name:@"OpenCreatedTorrentFile" object:nil];
|
||
|
||
[nc addObserver:self selector:@selector(applyFilter) name:@"UpdateGroups" object:nil];
|
||
|
||
[nc addObserver:self selector:@selector(updateWindowAfterToolbarChange) name:@"ToolbarDidChange" object:nil];
|
||
|
||
[self updateMainWindow];
|
||
|
||
if (@available(macOS 26.0, *))
|
||
;
|
||
else
|
||
{
|
||
// <#7908> Keep older macOS clean of visual noise
|
||
for (NSMenuItem* item in _fWindow.menu.itemArray)
|
||
for (NSMenuItem* subItem in item.submenu.itemArray)
|
||
subItem.image = nil;
|
||
}
|
||
|
||
//timer to update the interface every second
|
||
self.fTimer = [NSTimer scheduledTimerWithTimeInterval:kUpdateUISeconds target:self selector:@selector(updateUI) userInfo:nil
|
||
repeats:YES];
|
||
[NSRunLoop.currentRunLoop addTimer:self.fTimer forMode:NSModalPanelRunLoopMode];
|
||
[NSRunLoop.currentRunLoop addTimer:self.fTimer forMode:NSEventTrackingRunLoopMode];
|
||
|
||
[self.fWindow makeKeyAndOrderFront:nil];
|
||
|
||
if ([self.fDefaults boolForKey:@"InfoVisible"])
|
||
{
|
||
[self showInfo:nil];
|
||
}
|
||
}
|
||
|
||
#pragma mark - NSApplicationDelegate
|
||
|
||
- (void)applicationWillFinishLaunching:(NSNotification*)notification
|
||
{
|
||
// user notifications
|
||
UNUserNotificationCenter.currentNotificationCenter.delegate = self;
|
||
UNNotificationAction* actionShow = [UNNotificationAction actionWithIdentifier:@"actionShow"
|
||
title:NSLocalizedString(@"Show", "notification button")
|
||
options:UNNotificationActionOptionForeground];
|
||
UNNotificationCategory* categoryShow = [UNNotificationCategory categoryWithIdentifier:@"categoryShow" actions:@[ actionShow ]
|
||
intentIdentifiers:@[]
|
||
options:UNNotificationCategoryOptionNone];
|
||
[UNUserNotificationCenter.currentNotificationCenter setNotificationCategories:[NSSet setWithObject:categoryShow]];
|
||
[UNUserNotificationCenter.currentNotificationCenter
|
||
requestAuthorizationWithOptions:(UNAuthorizationOptionSound | UNAuthorizationOptionAlert | UNAuthorizationOptionBadge)
|
||
completionHandler:^(BOOL /*granted*/, NSError* _Nullable error) {
|
||
if (error.code > 0)
|
||
{
|
||
NSLog(@"UserNotifications not configured: %@", error.localizedDescription);
|
||
}
|
||
}];
|
||
}
|
||
|
||
- (void)applicationDidFinishLaunching:(NSNotification*)notification
|
||
{
|
||
//cover our asses
|
||
if ([NSUserDefaults.standardUserDefaults boolForKey:@"WarningLegal"])
|
||
{
|
||
NSAlert* alert = [[NSAlert alloc] init];
|
||
[alert addButtonWithTitle:NSLocalizedString(@"I Accept", "Legal alert -> button")];
|
||
[alert addButtonWithTitle:NSLocalizedString(@"Quit", "Legal alert -> button")];
|
||
alert.messageText = NSLocalizedString(@"Welcome to Transmission", "Legal alert -> title");
|
||
alert.informativeText = NSLocalizedString(
|
||
@"Transmission is a file-sharing program."
|
||
" When you run a torrent, its data will be made available to others by means of upload."
|
||
" You and you alone are fully responsible for exercising proper judgement and abiding by your local laws.",
|
||
"Legal alert -> message");
|
||
alert.alertStyle = NSAlertStyleInformational;
|
||
|
||
if ([alert runModal] == NSAlertSecondButtonReturn)
|
||
{
|
||
exit(0);
|
||
}
|
||
|
||
[NSUserDefaults.standardUserDefaults setBool:NO forKey:@"WarningLegal"];
|
||
}
|
||
|
||
NSApp.servicesProvider = self;
|
||
|
||
[PowerManager.shared setDelegate:self];
|
||
[PowerManager.shared start];
|
||
|
||
//register for dock icon drags (has to be in applicationDidFinishLaunching: to work)
|
||
[[NSAppleEventManager sharedAppleEventManager] setEventHandler:self andSelector:@selector(handleOpenContentsEvent:replyEvent:)
|
||
forEventClass:kCoreEventClass
|
||
andEventID:kAEOpenContents];
|
||
|
||
//if we were opened from a user notification, do the corresponding action
|
||
UNNotificationResponse* launchNotification = notification.userInfo[NSApplicationLaunchUserNotificationKey];
|
||
if (launchNotification)
|
||
{
|
||
[self userNotificationCenter:UNUserNotificationCenter.currentNotificationCenter didReceiveNotificationResponse:launchNotification
|
||
withCompletionHandler:^{
|
||
}];
|
||
}
|
||
|
||
//auto importing
|
||
[self checkAutoImportDirectory];
|
||
|
||
//registering the Web UI to Bonjour
|
||
if ([self.fDefaults boolForKey:@"RPC"] && [self.fDefaults boolForKey:@"RPCWebDiscovery"])
|
||
{
|
||
[BonjourController.defaultController startWithPort:static_cast<int>([self.fDefaults integerForKey:@"RPCPort"])];
|
||
}
|
||
|
||
//shamelessly ask for donations
|
||
if ([self.fDefaults boolForKey:@"WarningDonate"])
|
||
{
|
||
BOOL const firstLaunch = tr_sessionGetCumulativeStats(self.fLib).sessionCount <= 1;
|
||
|
||
NSDate* lastDonateDate = [self.fDefaults objectForKey:@"DonateAskDate"];
|
||
BOOL const timePassed = !lastDonateDate || (-1 * lastDonateDate.timeIntervalSinceNow) >= kDonateNagTime;
|
||
|
||
if (!firstLaunch && timePassed)
|
||
{
|
||
[self.fDefaults setObject:[NSDate date] forKey:@"DonateAskDate"];
|
||
|
||
NSAlert* alert = [[NSAlert alloc] init];
|
||
alert.messageText = NSLocalizedString(@"Support open-source indie software", "Donation beg -> title");
|
||
|
||
NSString* donateMessage = [NSString
|
||
stringWithFormat:@"%@\n\n%@",
|
||
NSLocalizedString(
|
||
@"Transmission is a full-featured torrent application."
|
||
" A lot of time and effort have gone into development, coding, and refinement."
|
||
" If you enjoy using it, please consider showing your love with a donation.",
|
||
"Donation beg -> message"),
|
||
NSLocalizedString(@"Donate or not, there will be no difference to your torrenting experience.", "Donation beg -> message")];
|
||
|
||
alert.informativeText = donateMessage;
|
||
alert.alertStyle = NSAlertStyleInformational;
|
||
|
||
[alert addButtonWithTitle:[NSLocalizedString(@"Donate", "Donation beg -> button") stringByAppendingEllipsis]];
|
||
NSButton* noDonateButton = [alert addButtonWithTitle:NSLocalizedString(@"Nope", "Donation beg -> button")];
|
||
noDonateButton.keyEquivalent = @"\e"; //escape key
|
||
|
||
// hide the "don't show again" check the first time - give them time to try the app
|
||
BOOL const allowNeverAgain = lastDonateDate != nil;
|
||
alert.showsSuppressionButton = allowNeverAgain;
|
||
if (allowNeverAgain)
|
||
{
|
||
alert.suppressionButton.title = NSLocalizedString(@"Don't bug me about this ever again.", "Donation beg -> button");
|
||
}
|
||
|
||
NSInteger const donateResult = [alert runModal];
|
||
if (donateResult == NSAlertFirstButtonReturn)
|
||
{
|
||
[self linkDonate:self];
|
||
}
|
||
|
||
if (allowNeverAgain)
|
||
{
|
||
[self.fDefaults setBool:(alert.suppressionButton.state != NSControlStateValueOn) forKey:@"WarningDonate"];
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
- (BOOL)applicationShouldHandleReopen:(NSApplication*)app hasVisibleWindows:(BOOL)visibleWindows
|
||
{
|
||
NSWindow* mainWindow = NSApp.mainWindow;
|
||
if (!mainWindow || !mainWindow.visible)
|
||
{
|
||
[self.fWindow makeKeyAndOrderFront:nil];
|
||
}
|
||
|
||
return NO;
|
||
}
|
||
|
||
- (NSApplicationTerminateReply)applicationShouldTerminate:(NSApplication*)sender
|
||
{
|
||
if (self.fQuitRequested || ![self.fDefaults boolForKey:@"CheckQuit"])
|
||
{
|
||
return NSTerminateNow;
|
||
}
|
||
|
||
NSUInteger active = 0, downloading = 0;
|
||
for (Torrent* torrent in self.fTorrents)
|
||
{
|
||
if (torrent.active && !torrent.stalled)
|
||
{
|
||
active++;
|
||
if (!torrent.allDownloaded)
|
||
{
|
||
downloading++;
|
||
}
|
||
}
|
||
}
|
||
|
||
BOOL preventedByTransfer = [self.fDefaults boolForKey:@"CheckQuitDownloading"] ? downloading > 0 : active > 0;
|
||
|
||
if (!preventedByTransfer)
|
||
{
|
||
return NSTerminateNow;
|
||
}
|
||
|
||
NSAlert* alert = [[NSAlert alloc] init];
|
||
alert.alertStyle = NSAlertStyleInformational;
|
||
alert.messageText = NSLocalizedString(@"Are you sure you want to quit?", "Confirm Quit panel -> title");
|
||
alert.informativeText = active == 1 ?
|
||
NSLocalizedString(
|
||
@"There is an active transfer that will be paused on quit."
|
||
" The transfer will automatically resume on the next launch.",
|
||
"Confirm Quit panel -> message") :
|
||
[NSString localizedStringWithFormat:NSLocalizedString(
|
||
@"There are %lu active transfers that will be paused on quit."
|
||
" The transfers will automatically resume on the next launch.",
|
||
"Confirm Quit panel -> message"),
|
||
active];
|
||
[alert addButtonWithTitle:NSLocalizedString(@"Quit", "Confirm Quit panel -> button")];
|
||
[alert addButtonWithTitle:NSLocalizedString(@"Cancel", "Confirm Quit panel -> button")];
|
||
alert.showsSuppressionButton = YES;
|
||
|
||
[alert beginSheetModalForWindow:self.fWindow completionHandler:^(NSModalResponse returnCode) {
|
||
if (alert.suppressionButton.state == NSControlStateValueOn)
|
||
{
|
||
[self.fDefaults setBool:NO forKey:@"CheckQuit"];
|
||
}
|
||
[NSApp replyToApplicationShouldTerminate:returnCode == NSAlertFirstButtonReturn];
|
||
}];
|
||
|
||
return NSTerminateLater;
|
||
}
|
||
|
||
- (void)applicationWillTerminate:(NSNotification*)notification
|
||
{
|
||
self.fQuitting = YES;
|
||
|
||
[PowerManager.shared stop];
|
||
|
||
//stop the Bonjour service
|
||
if (BonjourController.defaultControllerExists)
|
||
{
|
||
[BonjourController.defaultController stop];
|
||
}
|
||
|
||
//stop blocklist download
|
||
if (BlocklistDownloader.isRunning)
|
||
{
|
||
[[BlocklistDownloader downloader] cancelDownload];
|
||
}
|
||
|
||
//stop timers and notification checking
|
||
[NSNotificationCenter.defaultCenter removeObserver:self];
|
||
|
||
[self.fTimer invalidate];
|
||
|
||
if (self.fAutoImportTimer)
|
||
{
|
||
if (self.fAutoImportTimer.valid)
|
||
{
|
||
[self.fAutoImportTimer invalidate];
|
||
}
|
||
}
|
||
|
||
//remove all torrent downloads
|
||
[self.fSession invalidateAndCancel];
|
||
|
||
//remember window states
|
||
[self.fDefaults setBool:self.fInfoController.window.visible forKey:@"InfoVisible"];
|
||
|
||
if ([QLPreviewPanel sharedPreviewPanelExists] && [QLPreviewPanel sharedPreviewPanel].visible)
|
||
{
|
||
[[QLPreviewPanel sharedPreviewPanel] updateController];
|
||
}
|
||
|
||
// close all windows
|
||
for (NSWindow* window in NSApp.windows)
|
||
{
|
||
[window close];
|
||
}
|
||
|
||
// clear the badge
|
||
[self.fBadger updateBadgeWithDownload:0 upload:0];
|
||
|
||
//save history
|
||
[self updateTorrentHistory];
|
||
[self.fTableView saveCollapsedGroups];
|
||
|
||
_fileWatcherQueue = nil;
|
||
|
||
//complete cleanup: this can take many seconds
|
||
tr_sessionClose(self.fLib);
|
||
}
|
||
|
||
- (BOOL)applicationSupportsSecureRestorableState:(NSApplication*)app
|
||
{
|
||
return YES;
|
||
}
|
||
|
||
#pragma mark -
|
||
|
||
- (tr_session*)sessionHandle
|
||
{
|
||
return self.fLib;
|
||
}
|
||
|
||
- (void)handleOpenContentsEvent:(NSAppleEventDescriptor*)event replyEvent:(NSAppleEventDescriptor*)replyEvent
|
||
{
|
||
NSString* urlString = nil;
|
||
|
||
NSAppleEventDescriptor* directObject = [event paramDescriptorForKeyword:keyDirectObject];
|
||
if (directObject.descriptorType == typeAEList)
|
||
{
|
||
for (NSInteger i = 1; i <= directObject.numberOfItems; i++)
|
||
{
|
||
if ((urlString = [directObject descriptorAtIndex:i].stringValue))
|
||
{
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
else
|
||
{
|
||
urlString = directObject.stringValue;
|
||
}
|
||
|
||
if (urlString)
|
||
{
|
||
[self openURL:urlString];
|
||
}
|
||
}
|
||
|
||
#pragma mark - NSURLSessionDelegate
|
||
|
||
- (void)URLSession:(nonnull NSURLSession*)session
|
||
dataTask:(nonnull NSURLSessionDataTask*)dataTask
|
||
didReceiveResponse:(nonnull NSURLResponse*)response
|
||
completionHandler:(nonnull void (^)(NSURLSessionResponseDisposition))completionHandler
|
||
{
|
||
NSString* suggestedName = response.suggestedFilename;
|
||
if ([suggestedName.pathExtension caseInsensitiveCompare:@"torrent"] == NSOrderedSame)
|
||
{
|
||
completionHandler(NSURLSessionResponseBecomeDownload);
|
||
return;
|
||
}
|
||
completionHandler(NSURLSessionResponseCancel);
|
||
|
||
NSString* message = [NSString
|
||
stringWithFormat:NSLocalizedString(@"It appears that the file \"%@\" from %@ is not a torrent file.", "Download not a torrent -> message"),
|
||
suggestedName,
|
||
dataTask.originalRequest.URL.absoluteString.stringByRemovingPercentEncoding];
|
||
dispatch_async(dispatch_get_main_queue(), ^{
|
||
NSAlert* alert = [[NSAlert alloc] init];
|
||
[alert addButtonWithTitle:NSLocalizedString(@"OK", "Download not a torrent -> button")];
|
||
alert.messageText = NSLocalizedString(@"Torrent download failed", "Download not a torrent -> title");
|
||
alert.informativeText = message;
|
||
[alert runModal];
|
||
});
|
||
}
|
||
|
||
- (void)URLSession:(nonnull NSURLSession*)session
|
||
dataTask:(nonnull NSURLSessionDataTask*)dataTask
|
||
didBecomeDownloadTask:(nonnull NSURLSessionDownloadTask*)downloadTask
|
||
{
|
||
// Required delegate method to proceed with NSURLSessionResponseBecomeDownload.
|
||
// nothing to do
|
||
}
|
||
|
||
- (void)URLSession:(nonnull NSURLSession*)session
|
||
downloadTask:(nonnull NSURLSessionDownloadTask*)downloadTask
|
||
didFinishDownloadingToURL:(nonnull NSURL*)location
|
||
{
|
||
NSString* path = [NSTemporaryDirectory() stringByAppendingPathComponent:downloadTask.response.suggestedFilename.lastPathComponent];
|
||
NSError* error;
|
||
[NSFileManager.defaultManager moveItemAtPath:location.path toPath:path error:&error];
|
||
if (error)
|
||
{
|
||
[self URLSession:session task:downloadTask didCompleteWithError:error];
|
||
return;
|
||
}
|
||
|
||
dispatch_async(dispatch_get_main_queue(), ^{
|
||
[self openFiles:@[ path ] addType:AddTypeURL forcePath:nil];
|
||
|
||
//delete the torrent file after opening
|
||
[NSFileManager.defaultManager removeItemAtPath:path error:NULL];
|
||
});
|
||
}
|
||
|
||
- (void)URLSession:(nonnull NSURLSession*)session
|
||
task:(nonnull NSURLSessionTask*)task
|
||
didCompleteWithError:(nullable NSError*)error
|
||
{
|
||
if (!error || error.code == NSURLErrorCancelled)
|
||
{
|
||
// no errors or we already displayed an alert
|
||
return;
|
||
}
|
||
|
||
NSString* urlString = task.currentRequest.URL.absoluteString;
|
||
if ([urlString rangeOfString:@"magnet:" options:(NSAnchoredSearch | NSCaseInsensitiveSearch)].location != NSNotFound)
|
||
{
|
||
// originalRequest was a redirect to a magnet
|
||
[self performSelectorOnMainThread:@selector(openMagnet:) withObject:urlString waitUntilDone:NO];
|
||
return;
|
||
}
|
||
|
||
NSString* message = [NSString
|
||
stringWithFormat:NSLocalizedString(@"The torrent could not be downloaded from %@: %@.", "Torrent download failed -> message"),
|
||
task.originalRequest.URL.absoluteString.stringByRemovingPercentEncoding,
|
||
error.localizedDescription];
|
||
dispatch_async(dispatch_get_main_queue(), ^{
|
||
NSAlert* alert = [[NSAlert alloc] init];
|
||
[alert addButtonWithTitle:NSLocalizedString(@"OK", "Torrent download failed -> button")];
|
||
alert.messageText = NSLocalizedString(@"Torrent download failed", "Torrent download error -> title");
|
||
alert.informativeText = message;
|
||
[alert runModal];
|
||
});
|
||
}
|
||
|
||
#pragma mark -
|
||
|
||
- (void)application:(NSApplication*)app openFiles:(NSArray<NSString*>*)filenames
|
||
{
|
||
[self openFiles:filenames addType:AddTypeManual forcePath:nil];
|
||
}
|
||
|
||
- (void)openFiles:(NSArray<NSString*>*)filenames addType:(AddType)type forcePath:(NSString*)path
|
||
{
|
||
BOOL deleteTorrentFile, canToggleDelete = NO;
|
||
switch (type)
|
||
{
|
||
case AddTypeCreated:
|
||
deleteTorrentFile = NO;
|
||
break;
|
||
case AddTypeURL:
|
||
deleteTorrentFile = YES;
|
||
break;
|
||
default:
|
||
deleteTorrentFile = [self.fDefaults boolForKey:@"DeleteOriginalTorrent"];
|
||
canToggleDelete = YES;
|
||
}
|
||
|
||
for (NSString* torrentPath in filenames)
|
||
{
|
||
auto metainfo = tr_torrent_metainfo{};
|
||
if (!metainfo.parse_torrent_file(torrentPath.UTF8String)) // invalid torrent
|
||
{
|
||
if (type != AddTypeAuto)
|
||
{
|
||
[self invalidOpenAlert:torrentPath.lastPathComponent];
|
||
}
|
||
continue;
|
||
}
|
||
|
||
auto foundTorrent = tr_torrentFindFromMetainfo(self.fLib, &metainfo);
|
||
if (foundTorrent != nullptr) // dupe torrent
|
||
{
|
||
if (tr_torrentHasMetadata(foundTorrent))
|
||
{
|
||
[self duplicateOpenAlert:@(metainfo.name().c_str())];
|
||
}
|
||
// foundTorrent is a magnet, fill it with file's metainfo
|
||
else if (!tr_torrentSetMetainfoFromFile(foundTorrent, &metainfo, torrentPath.UTF8String))
|
||
{
|
||
[self duplicateOpenAlert:@(metainfo.name().c_str())];
|
||
}
|
||
continue;
|
||
}
|
||
|
||
//determine download location
|
||
NSString* location;
|
||
BOOL lockDestination = NO; //don't override the location with a group location if it has a hardcoded path
|
||
if (path)
|
||
{
|
||
location = path.stringByExpandingTildeInPath;
|
||
lockDestination = YES;
|
||
}
|
||
else if ([self.fDefaults boolForKey:@"DownloadLocationConstant"])
|
||
{
|
||
location = [self.fDefaults stringForKey:@"DownloadFolder"].stringByExpandingTildeInPath;
|
||
}
|
||
else if (type != AddTypeURL)
|
||
{
|
||
location = torrentPath.stringByDeletingLastPathComponent;
|
||
}
|
||
else
|
||
{
|
||
location = nil;
|
||
}
|
||
|
||
//determine to show the options window
|
||
auto const is_multifile = metainfo.file_count() > 1;
|
||
BOOL const showWindow = type == AddTypeShowOptions ||
|
||
([self.fDefaults boolForKey:@"DownloadAsk"] && (is_multifile || ![self.fDefaults boolForKey:@"DownloadAskMulti"]) &&
|
||
(type != AddTypeAuto || ![self.fDefaults boolForKey:@"DownloadAskManual"]));
|
||
|
||
Torrent* torrent;
|
||
if (!(torrent = [[Torrent alloc] initWithPath:torrentPath location:location
|
||
deleteTorrentFile:showWindow ? NO : deleteTorrentFile
|
||
lib:self.fLib]))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
//change the location if the group calls for it (this has to wait until after the torrent is created)
|
||
if (!lockDestination && [GroupsController.groups usesCustomDownloadLocationForIndex:torrent.groupValue])
|
||
{
|
||
location = [GroupsController.groups customDownloadLocationForIndex:torrent.groupValue];
|
||
[torrent changeDownloadFolderBeforeUsing:location determinationType:TorrentDeterminationAutomatic];
|
||
}
|
||
|
||
//verify the data right away if it was newly created
|
||
if (type == AddTypeCreated)
|
||
{
|
||
[torrent resetCache];
|
||
}
|
||
|
||
//show the add window or add directly
|
||
if (showWindow || !location)
|
||
{
|
||
AddWindowController* addController = [[AddWindowController alloc] initWithTorrent:torrent destination:location
|
||
lockDestination:lockDestination
|
||
controller:self
|
||
torrentFile:torrentPath
|
||
deleteTorrentCheckEnableInitially:deleteTorrentFile
|
||
canToggleDelete:canToggleDelete];
|
||
[addController showWindow:self];
|
||
|
||
if (!self.fAddWindows)
|
||
{
|
||
self.fAddWindows = [[NSMutableSet alloc] init];
|
||
}
|
||
[self.fAddWindows addObject:addController];
|
||
}
|
||
else
|
||
{
|
||
if ([self.fDefaults boolForKey:@"AutoStartDownload"])
|
||
{
|
||
[torrent startTransfer];
|
||
}
|
||
|
||
[torrent update];
|
||
[self.fTorrents addObject:torrent];
|
||
|
||
if (!self.fAddingTransfers)
|
||
{
|
||
self.fAddingTransfers = [[NSMutableSet alloc] init];
|
||
}
|
||
[self.fAddingTransfers addObject:torrent];
|
||
}
|
||
}
|
||
|
||
[self fullUpdateUI];
|
||
}
|
||
|
||
- (void)askOpenConfirmed:(AddWindowController*)addController add:(BOOL)add
|
||
{
|
||
Torrent* torrent = addController.torrent;
|
||
|
||
if (add)
|
||
{
|
||
torrent.queuePosition = self.fTorrents.count;
|
||
|
||
[torrent update];
|
||
[self.fTorrents addObject:torrent];
|
||
|
||
if (!self.fAddingTransfers)
|
||
{
|
||
self.fAddingTransfers = [[NSMutableSet alloc] init];
|
||
}
|
||
[self.fAddingTransfers addObject:torrent];
|
||
|
||
[self fullUpdateUI];
|
||
}
|
||
else
|
||
{
|
||
[torrent closeRemoveTorrent:NO];
|
||
}
|
||
|
||
[self.fAddWindows removeObject:addController];
|
||
if (self.fAddWindows.count == 0)
|
||
{
|
||
self.fAddWindows = nil;
|
||
}
|
||
}
|
||
|
||
- (void)openMagnet:(NSString*)address
|
||
{
|
||
tr_torrent* duplicateTorrent;
|
||
if ((duplicateTorrent = tr_torrentFindFromMagnetLink(self.fLib, address.UTF8String)))
|
||
{
|
||
NSString* name = @(tr_torrentName(duplicateTorrent));
|
||
[self duplicateOpenMagnetAlert:address transferName:name];
|
||
return;
|
||
}
|
||
|
||
//determine download location
|
||
NSString* location = nil;
|
||
if ([self.fDefaults boolForKey:@"DownloadLocationConstant"])
|
||
{
|
||
location = [self.fDefaults stringForKey:@"DownloadFolder"].stringByExpandingTildeInPath;
|
||
}
|
||
|
||
Torrent* torrent;
|
||
if (!(torrent = [[Torrent alloc] initWithMagnetAddress:address location:location lib:self.fLib]))
|
||
{
|
||
[self invalidOpenMagnetAlert:address];
|
||
return;
|
||
}
|
||
|
||
//change the location if the group calls for it (this has to wait until after the torrent is created)
|
||
if ([GroupsController.groups usesCustomDownloadLocationForIndex:torrent.groupValue])
|
||
{
|
||
location = [GroupsController.groups customDownloadLocationForIndex:torrent.groupValue];
|
||
[torrent changeDownloadFolderBeforeUsing:location determinationType:TorrentDeterminationAutomatic];
|
||
}
|
||
|
||
if ([self.fDefaults boolForKey:@"MagnetOpenAsk"] || !location)
|
||
{
|
||
AddMagnetWindowController* addController = [[AddMagnetWindowController alloc] initWithTorrent:torrent destination:location
|
||
controller:self];
|
||
[addController showWindow:self];
|
||
|
||
if (!self.fAddWindows)
|
||
{
|
||
self.fAddWindows = [[NSMutableSet alloc] init];
|
||
}
|
||
[self.fAddWindows addObject:addController];
|
||
}
|
||
else
|
||
{
|
||
if ([self.fDefaults boolForKey:@"AutoStartDownload"])
|
||
{
|
||
[torrent startTransfer];
|
||
}
|
||
|
||
[torrent update];
|
||
[self.fTorrents addObject:torrent];
|
||
|
||
if (!self.fAddingTransfers)
|
||
{
|
||
self.fAddingTransfers = [[NSMutableSet alloc] init];
|
||
}
|
||
[self.fAddingTransfers addObject:torrent];
|
||
}
|
||
|
||
[self fullUpdateUI];
|
||
}
|
||
|
||
- (void)askOpenMagnetConfirmed:(AddMagnetWindowController*)addController add:(BOOL)add
|
||
{
|
||
Torrent* torrent = addController.torrent;
|
||
|
||
if (add)
|
||
{
|
||
torrent.queuePosition = self.fTorrents.count;
|
||
|
||
[torrent update];
|
||
[self.fTorrents addObject:torrent];
|
||
|
||
if (!self.fAddingTransfers)
|
||
{
|
||
self.fAddingTransfers = [[NSMutableSet alloc] init];
|
||
}
|
||
[self.fAddingTransfers addObject:torrent];
|
||
|
||
[self fullUpdateUI];
|
||
}
|
||
else
|
||
{
|
||
[torrent closeRemoveTorrent:NO];
|
||
}
|
||
|
||
[self.fAddWindows removeObject:addController];
|
||
if (self.fAddWindows.count == 0)
|
||
{
|
||
self.fAddWindows = nil;
|
||
}
|
||
}
|
||
|
||
- (void)openCreatedFile:(NSNotification*)notification
|
||
{
|
||
NSDictionary* dict = notification.userInfo;
|
||
[self openFiles:@[ dict[@"File"] ] addType:AddTypeCreated forcePath:dict[@"Path"]];
|
||
}
|
||
|
||
- (void)openFilesWithDict:(NSDictionary*)dictionary
|
||
{
|
||
[self openFiles:dictionary[@"Filenames"] addType:static_cast<AddType>([dictionary[@"AddType"] intValue]) forcePath:nil];
|
||
}
|
||
|
||
//called on by applescript
|
||
- (void)open:(NSArray*)files
|
||
{
|
||
NSDictionary* dict = @{ @"Filenames" : files, @"AddType" : @(AddTypeManual) };
|
||
[self performSelectorOnMainThread:@selector(openFilesWithDict:) withObject:dict waitUntilDone:NO];
|
||
}
|
||
|
||
- (void)openShowSheet:(id)sender
|
||
{
|
||
NSOpenPanel* panel = [NSOpenPanel openPanel];
|
||
|
||
panel.allowsMultipleSelection = YES;
|
||
panel.canChooseFiles = YES;
|
||
panel.canChooseDirectories = NO;
|
||
|
||
panel.allowedFileTypes = @[ @"org.bittorrent.torrent", @"torrent" ];
|
||
|
||
[panel beginSheetModalForWindow:self.fWindow completionHandler:^(NSInteger result) {
|
||
if (result == NSModalResponseOK)
|
||
{
|
||
NSMutableArray* filenames = [NSMutableArray arrayWithCapacity:panel.URLs.count];
|
||
for (NSURL* url in panel.URLs)
|
||
{
|
||
[filenames addObject:url.path];
|
||
}
|
||
|
||
NSDictionary* dictionary = @{
|
||
@"Filenames" : filenames,
|
||
@"AddType" : sender == self.fOpenIgnoreDownloadFolder ? @(AddTypeShowOptions) : @(AddTypeManual)
|
||
};
|
||
[self performSelectorOnMainThread:@selector(openFilesWithDict:) withObject:dictionary waitUntilDone:NO];
|
||
}
|
||
}];
|
||
}
|
||
|
||
- (void)invalidOpenAlert:(NSString*)filename
|
||
{
|
||
if (![self.fDefaults boolForKey:@"WarningInvalidOpen"])
|
||
{
|
||
return;
|
||
}
|
||
|
||
NSAlert* alert = [[NSAlert alloc] init];
|
||
alert.messageText = [NSString
|
||
stringWithFormat:NSLocalizedString(@"\"%@\" is not a valid torrent file.", "Open invalid alert -> title"), filename];
|
||
alert.informativeText = NSLocalizedString(@"The torrent file cannot be opened because it contains invalid data.", "Open invalid alert -> message");
|
||
|
||
alert.alertStyle = NSAlertStyleWarning;
|
||
[alert addButtonWithTitle:NSLocalizedString(@"OK", "Open invalid alert -> button")];
|
||
|
||
[alert runModal];
|
||
if (alert.suppressionButton.state == NSControlStateValueOn)
|
||
{
|
||
[self.fDefaults setBool:NO forKey:@"WarningInvalidOpen"];
|
||
}
|
||
}
|
||
|
||
- (void)invalidOpenMagnetAlert:(NSString*)address
|
||
{
|
||
if (![self.fDefaults boolForKey:@"WarningInvalidOpen"])
|
||
{
|
||
return;
|
||
}
|
||
|
||
NSAlert* alert = [[NSAlert alloc] init];
|
||
alert.messageText = NSLocalizedString(@"Adding magnetized transfer failed.", "Magnet link failed -> title");
|
||
alert.informativeText = [NSString stringWithFormat:NSLocalizedString(
|
||
@"There was an error when adding the magnet link \"%@\"."
|
||
" The transfer will not occur.",
|
||
"Magnet link failed -> message"),
|
||
address];
|
||
alert.alertStyle = NSAlertStyleWarning;
|
||
[alert addButtonWithTitle:NSLocalizedString(@"OK", "Magnet link failed -> button")];
|
||
|
||
[alert runModal];
|
||
if (alert.suppressionButton.state == NSControlStateValueOn)
|
||
{
|
||
[self.fDefaults setBool:NO forKey:@"WarningInvalidOpen"];
|
||
}
|
||
}
|
||
|
||
- (void)duplicateOpenAlert:(NSString*)name
|
||
{
|
||
if (![self.fDefaults boolForKey:@"WarningDuplicate"])
|
||
{
|
||
return;
|
||
}
|
||
|
||
NSAlert* alert = [[NSAlert alloc] init];
|
||
alert.messageText = [NSString
|
||
stringWithFormat:NSLocalizedString(@"A transfer of \"%@\" already exists.", "Open duplicate alert -> title"), name];
|
||
alert.informativeText = NSLocalizedString(
|
||
@"The transfer cannot be added because it is a duplicate of an already existing transfer.",
|
||
"Open duplicate alert -> message");
|
||
|
||
alert.alertStyle = NSAlertStyleWarning;
|
||
[alert addButtonWithTitle:NSLocalizedString(@"OK", "Open duplicate alert -> button")];
|
||
alert.showsSuppressionButton = YES;
|
||
|
||
[alert runModal];
|
||
if (alert.suppressionButton.state)
|
||
{
|
||
[self.fDefaults setBool:NO forKey:@"WarningDuplicate"];
|
||
}
|
||
}
|
||
|
||
- (void)duplicateOpenMagnetAlert:(NSString*)address transferName:(NSString*)name
|
||
{
|
||
if (![self.fDefaults boolForKey:@"WarningDuplicate"])
|
||
{
|
||
return;
|
||
}
|
||
|
||
NSAlert* alert = [[NSAlert alloc] init];
|
||
if (name)
|
||
{
|
||
alert.messageText = [NSString
|
||
stringWithFormat:NSLocalizedString(@"A transfer of \"%@\" already exists.", "Open duplicate magnet alert -> title"), name];
|
||
}
|
||
else
|
||
{
|
||
alert.messageText = NSLocalizedString(@"Magnet link is a duplicate of an existing transfer.", "Open duplicate magnet alert -> title");
|
||
}
|
||
alert.informativeText = [NSString
|
||
stringWithFormat:NSLocalizedString(
|
||
@"The magnet link \"%@\" cannot be added because it is a duplicate of an already existing transfer.",
|
||
"Open duplicate magnet alert -> message"),
|
||
address];
|
||
alert.alertStyle = NSAlertStyleWarning;
|
||
[alert addButtonWithTitle:NSLocalizedString(@"OK", "Open duplicate magnet alert -> button")];
|
||
alert.showsSuppressionButton = YES;
|
||
|
||
[alert runModal];
|
||
if (alert.suppressionButton.state)
|
||
{
|
||
[self.fDefaults setBool:NO forKey:@"WarningDuplicate"];
|
||
}
|
||
}
|
||
|
||
- (void)openURL:(NSString*)urlString
|
||
{
|
||
if ([urlString rangeOfString:@"magnet:" options:(NSAnchoredSearch | NSCaseInsensitiveSearch)].location != NSNotFound)
|
||
{
|
||
[self openMagnet:urlString];
|
||
}
|
||
else
|
||
{
|
||
if ([urlString rangeOfString:@"://"].location == NSNotFound)
|
||
{
|
||
if ([urlString rangeOfString:@"."].location == NSNotFound)
|
||
{
|
||
NSInteger beforeCom;
|
||
if ((beforeCom = [urlString rangeOfString:@"/"].location) != NSNotFound)
|
||
{
|
||
urlString = [NSString stringWithFormat:@"http://www.%@.com/%@",
|
||
[urlString substringToIndex:beforeCom],
|
||
[urlString substringFromIndex:beforeCom + 1]];
|
||
}
|
||
else
|
||
{
|
||
urlString = [NSString stringWithFormat:@"http://www.%@.com/", urlString];
|
||
}
|
||
}
|
||
else
|
||
{
|
||
urlString = [@"http://" stringByAppendingString:urlString];
|
||
}
|
||
}
|
||
|
||
NSURL* url = [NSURL URLWithString:urlString];
|
||
if (url == nil)
|
||
{
|
||
NSLog(@"Detected non-URL string \"%@\". Ignoring.", urlString);
|
||
return;
|
||
}
|
||
|
||
[self.fSession getAllTasksWithCompletionHandler:^(NSArray<__kindof NSURLSessionTask*>* _Nonnull tasks) {
|
||
for (NSURLSessionTask* task in tasks)
|
||
{
|
||
if ([task.originalRequest.URL isEqual:url])
|
||
{
|
||
NSLog(@"Already downloading %@", url);
|
||
return;
|
||
}
|
||
}
|
||
NSURLSessionDataTask* download = [self.fSession dataTaskWithURL:url];
|
||
[download resume];
|
||
}];
|
||
}
|
||
}
|
||
|
||
- (void)openURLShowSheet:(id)sender
|
||
{
|
||
if (!self.fUrlSheetController)
|
||
{
|
||
self.fUrlSheetController = [[URLSheetWindowController alloc] init];
|
||
|
||
[self.fWindow beginSheet:self.fUrlSheetController.window completionHandler:^(NSModalResponse returnCode) {
|
||
if (returnCode == 1)
|
||
{
|
||
NSString* urlString = self.fUrlSheetController.urlString;
|
||
urlString = [urlString stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet];
|
||
dispatch_async(dispatch_get_main_queue(), ^{
|
||
[self openURL:urlString];
|
||
});
|
||
}
|
||
self.fUrlSheetController = nil;
|
||
}];
|
||
}
|
||
}
|
||
|
||
- (void)openPasteboard
|
||
{
|
||
// 1. If Pasteboard contains URL objects, we treat those and only those
|
||
NSArray<NSURL*>* arrayOfURLs = [NSPasteboard.generalPasteboard readObjectsForClasses:@[ [NSURL class] ] options:nil];
|
||
|
||
if (arrayOfURLs.count > 0)
|
||
{
|
||
for (NSURL* url in arrayOfURLs)
|
||
{
|
||
[self openURL:url.absoluteString];
|
||
}
|
||
return;
|
||
}
|
||
|
||
// 2. If Pasteboard contains String objects, we'll search for both links and magnets
|
||
NSArray<NSString*>* arrayOfStrings = [NSPasteboard.generalPasteboard readObjectsForClasses:@[ [NSString class] ] options:nil];
|
||
if (arrayOfStrings.count == 0)
|
||
{
|
||
return;
|
||
}
|
||
// The link detector (can't detect magnets)
|
||
NSDataDetector* linkDetector = [NSDataDetector dataDetectorWithTypes:NSTextCheckingTypeLink error:nil];
|
||
// The magnet detector
|
||
// https://www.bittorrent.org/beps/bep_0009.html defines the magnet URI format as `magnet:?query` where query is non-empty.
|
||
// https://datatracker.ietf.org/doc/html/rfc3986 defines the query format rigorously as `([!$\&-;=?-Z_a-z~]|%[0-9A-F]{2})*`.
|
||
// But `tr_urlParse` acknowledges that magnet links can be malformed by "not escaping text in the display name".
|
||
// Those malformed magnets aren't URI anymore, and since a display name can potentially contain any Unicode except '/' (see `isUnixReservedChar`), we may want to be liberal on what we accept.
|
||
// In practice, copy-pasted magnets might most often be separated by Horizontal tab, Line feed, Carriage Return, Space, XML delimiters '<' '>', JSON delimiter '"' and Markdown delimiter '`'.
|
||
// But for now, we'll keep the historical separator choice from 8392476b30491ffe7d8d64210f5cf3c3dd1d69ca, whitespaceAndNewlineCharacterSet, which is `[\p{Z}\v]`.
|
||
NSRegularExpression* magnetDetector = [NSRegularExpression regularExpressionWithPattern:@"magnet:?([^\\p{Z}\\v])+" options:kNilOptions
|
||
error:nil];
|
||
for (NSString* itemString in arrayOfStrings)
|
||
{
|
||
// We open all links
|
||
for (NSTextCheckingResult* result in [linkDetector matchesInString:itemString options:0
|
||
range:NSMakeRange(0, itemString.length)])
|
||
{
|
||
[self openURL:result.URL.absoluteString];
|
||
}
|
||
|
||
// We open all magnets
|
||
for (NSTextCheckingResult* result in [magnetDetector matchesInString:itemString options:0
|
||
range:NSMakeRange(0, itemString.length)])
|
||
{
|
||
[self openURL:[itemString substringWithRange:result.range]];
|
||
}
|
||
}
|
||
}
|
||
|
||
- (void)createFile:(id)sender
|
||
{
|
||
[CreatorWindowController createTorrentFile:self.fLib];
|
||
}
|
||
|
||
- (void)resumeSelectedTorrents:(id)sender
|
||
{
|
||
[self resumeTorrents:self.fTableView.selectedTorrents];
|
||
}
|
||
|
||
- (void)resumeAllTorrents:(id)sender
|
||
{
|
||
NSMutableArray<Torrent*>* torrents = [NSMutableArray arrayWithCapacity:self.fTorrents.count];
|
||
|
||
for (Torrent* torrent in self.fTorrents)
|
||
{
|
||
if (!torrent.finishedSeeding)
|
||
{
|
||
[torrents addObject:torrent];
|
||
}
|
||
}
|
||
|
||
[self resumeTorrents:torrents];
|
||
}
|
||
|
||
- (void)resumeTorrents:(NSArray<Torrent*>*)torrents
|
||
{
|
||
for (Torrent* torrent in torrents)
|
||
{
|
||
[torrent startTransfer];
|
||
}
|
||
|
||
[self fullUpdateUI];
|
||
}
|
||
|
||
- (void)resumeSelectedTorrentsNoWait:(id)sender
|
||
{
|
||
[self resumeTorrentsNoWait:self.fTableView.selectedTorrents];
|
||
}
|
||
|
||
- (void)resumeWaitingTorrents:(id)sender
|
||
{
|
||
NSMutableArray<Torrent*>* torrents = [NSMutableArray arrayWithCapacity:self.fTorrents.count];
|
||
|
||
for (Torrent* torrent in self.fTorrents)
|
||
{
|
||
if (torrent.waitingToStart)
|
||
{
|
||
[torrents addObject:torrent];
|
||
}
|
||
}
|
||
|
||
[self resumeTorrentsNoWait:torrents];
|
||
}
|
||
|
||
- (void)resumeTorrentsNoWait:(NSArray<Torrent*>*)torrents
|
||
{
|
||
//iterate through instead of all at once to ensure no conflicts
|
||
for (Torrent* torrent in torrents)
|
||
{
|
||
[torrent startTransferNoQueue];
|
||
}
|
||
|
||
[self fullUpdateUI];
|
||
}
|
||
|
||
- (void)stopSelectedTorrents:(id)sender
|
||
{
|
||
[self stopTorrents:self.fTableView.selectedTorrents];
|
||
}
|
||
|
||
- (void)stopAllTorrents:(id)sender
|
||
{
|
||
[self stopTorrents:self.fTorrents];
|
||
}
|
||
|
||
- (void)stopTorrents:(NSArray<Torrent*>*)torrents
|
||
{
|
||
//don't want any of these starting then stopping
|
||
for (Torrent* torrent in torrents)
|
||
{
|
||
if (torrent.waitingToStart)
|
||
{
|
||
[torrent stopTransfer];
|
||
}
|
||
}
|
||
|
||
for (Torrent* torrent in torrents)
|
||
{
|
||
[torrent stopTransfer];
|
||
}
|
||
|
||
[self fullUpdateUI];
|
||
}
|
||
|
||
- (void)removeTorrents:(NSArray<Torrent*>*)torrents deleteData:(BOOL)deleteData
|
||
{
|
||
if ([self.fDefaults boolForKey:@"CheckRemove"])
|
||
{
|
||
NSUInteger active = 0, downloading = 0;
|
||
for (Torrent* torrent in torrents)
|
||
{
|
||
if (torrent.active)
|
||
{
|
||
++active;
|
||
if (!torrent.seeding)
|
||
{
|
||
++downloading;
|
||
}
|
||
}
|
||
}
|
||
|
||
if ([self.fDefaults boolForKey:@"CheckRemoveDownloading"] ? downloading > 0 : active > 0)
|
||
{
|
||
NSString *title, *message;
|
||
|
||
NSUInteger const selected = torrents.count;
|
||
if (selected == 1)
|
||
{
|
||
NSString* torrentName = torrents[0].name;
|
||
|
||
if (deleteData)
|
||
{
|
||
title = [NSString stringWithFormat:NSLocalizedString(
|
||
@"Are you sure you want to remove \"%@\" from the transfer list"
|
||
" and trash the data file?",
|
||
"Removal confirm panel -> title"),
|
||
torrentName];
|
||
}
|
||
else
|
||
{
|
||
title = [NSString
|
||
stringWithFormat:NSLocalizedString(@"Are you sure you want to remove \"%@\" from the transfer list?", "Removal confirm panel -> title"),
|
||
torrentName];
|
||
}
|
||
|
||
message = NSLocalizedString(
|
||
@"This transfer is active."
|
||
" Once removed, continuing the transfer will require the torrent file or magnet link.",
|
||
"Removal confirm panel -> message");
|
||
}
|
||
else
|
||
{
|
||
if (deleteData)
|
||
{
|
||
title = [NSString localizedStringWithFormat:NSLocalizedString(
|
||
@"Are you sure you want to remove %lu transfers from the transfer list"
|
||
" and trash the data files?",
|
||
"Removal confirm panel -> title"),
|
||
selected];
|
||
}
|
||
else
|
||
{
|
||
title = [NSString localizedStringWithFormat:NSLocalizedString(
|
||
@"Are you sure you want to remove %lu transfers from the transfer list?",
|
||
"Removal confirm panel -> title"),
|
||
selected];
|
||
}
|
||
|
||
if (selected == active)
|
||
{
|
||
message = [NSString localizedStringWithFormat:NSLocalizedString(@"There are %lu active transfers.", "Removal confirm panel -> message part 1"),
|
||
active];
|
||
}
|
||
else
|
||
{
|
||
message = [NSString localizedStringWithFormat:NSLocalizedString(@"There are %1$lu transfers (%2$lu active).", "Removal confirm panel -> message part 1"),
|
||
selected,
|
||
active];
|
||
}
|
||
message = [message stringByAppendingFormat:@" %@",
|
||
NSLocalizedString(
|
||
@"Once removed, continuing the transfers will require the torrent files or magnet links.",
|
||
"Removal confirm panel -> message part 2")];
|
||
}
|
||
|
||
NSAlert* alert = [[NSAlert alloc] init];
|
||
alert.alertStyle = NSAlertStyleInformational;
|
||
alert.messageText = title;
|
||
alert.informativeText = message;
|
||
[alert addButtonWithTitle:NSLocalizedString(@"Remove", "Removal confirm panel -> button")];
|
||
[alert addButtonWithTitle:NSLocalizedString(@"Cancel", "Removal confirm panel -> button")];
|
||
|
||
[alert beginSheetModalForWindow:self.fWindow completionHandler:^(NSModalResponse returnCode) {
|
||
if (returnCode == NSAlertFirstButtonReturn)
|
||
{
|
||
[self confirmRemoveTorrents:torrents deleteData:deleteData];
|
||
}
|
||
}];
|
||
return;
|
||
}
|
||
}
|
||
|
||
[self confirmRemoveTorrents:torrents deleteData:deleteData];
|
||
}
|
||
|
||
- (void)confirmRemoveTorrents:(NSArray<Torrent*>*)torrents deleteData:(BOOL)deleteData
|
||
{
|
||
//miscellaneous
|
||
for (Torrent* torrent in torrents)
|
||
{
|
||
//don't want any of these starting then stopping
|
||
if (torrent.waitingToStart)
|
||
{
|
||
[torrent stopTransfer];
|
||
}
|
||
|
||
//let's expand all groups that have removed items - they either don't exist anymore, are already expanded, or are collapsed (rpc)
|
||
[self.fTableView removeCollapsedGroup:torrent.groupValue];
|
||
|
||
//we can't assume the window is active - RPC removal, for example
|
||
[self.fBadger removeTorrent:torrent];
|
||
}
|
||
|
||
//#5106 - don't try to remove torrents that have already been removed (fix for a bug, but better safe than crash anyway)
|
||
NSIndexSet* indexesToRemove = [torrents indexesOfObjectsWithOptions:NSEnumerationConcurrent
|
||
passingTest:^BOOL(Torrent* torrent, NSUInteger /*idx*/, BOOL* /*stop*/) {
|
||
return [self.fTorrents indexOfObjectIdenticalTo:torrent] != NSNotFound;
|
||
}];
|
||
if (torrents.count != indexesToRemove.count)
|
||
{
|
||
NSLog(
|
||
@"trying to remove %ld transfers, but %ld have already been removed",
|
||
torrents.count,
|
||
torrents.count - indexesToRemove.count);
|
||
torrents = [torrents objectsAtIndexes:indexesToRemove];
|
||
|
||
if (indexesToRemove.count == 0)
|
||
{
|
||
[self fullUpdateUI];
|
||
return;
|
||
}
|
||
}
|
||
|
||
[self.fTorrents removeObjectsInArray:torrents];
|
||
|
||
//set up helpers to remove from the table
|
||
__block BOOL beganUpdate = NO;
|
||
|
||
void (^doTableRemoval)(NSMutableArray*, id) = ^(NSMutableArray<Torrent*>* displayedTorrents, id parent) {
|
||
NSIndexSet* indexes = [displayedTorrents indexesOfObjectsWithOptions:NSEnumerationConcurrent
|
||
passingTest:^BOOL(Torrent* obj, NSUInteger /*idx*/, BOOL* /*stop*/) {
|
||
return [torrents containsObject:obj];
|
||
}];
|
||
|
||
if (indexes.count > 0)
|
||
{
|
||
if (!beganUpdate)
|
||
{
|
||
[NSAnimationContext beginGrouping]; //this has to be before we set the completion handler (#4874)
|
||
|
||
//we can't closeRemoveTorrent: until it's no longer in the GUI at all
|
||
NSAnimationContext.currentContext.completionHandler = ^{
|
||
for (Torrent* torrent in torrents)
|
||
{
|
||
[torrent closeRemoveTorrent:deleteData];
|
||
}
|
||
|
||
[self fullUpdateUI];
|
||
};
|
||
|
||
[self.fTableView beginUpdates];
|
||
beganUpdate = YES;
|
||
}
|
||
|
||
[self.fTableView removeItemsAtIndexes:indexes inParent:parent withAnimation:NSTableViewAnimationSlideLeft];
|
||
|
||
[displayedTorrents removeObjectsAtIndexes:indexes];
|
||
}
|
||
};
|
||
|
||
//if not removed from the displayed torrents here, fullUpdateUI might cause a crash
|
||
if (self.fDisplayedTorrents.count > 0)
|
||
{
|
||
if ([self.fDisplayedTorrents[0] isKindOfClass:[TorrentGroup class]])
|
||
{
|
||
for (TorrentGroup* group in self.fDisplayedTorrents)
|
||
{
|
||
doTableRemoval(group.torrents, group);
|
||
}
|
||
}
|
||
else
|
||
{
|
||
doTableRemoval(self.fDisplayedTorrents, nil);
|
||
}
|
||
|
||
if (beganUpdate)
|
||
{
|
||
[self.fTableView endUpdates];
|
||
[NSAnimationContext endGrouping];
|
||
}
|
||
}
|
||
|
||
if (!beganUpdate)
|
||
{
|
||
//do here if we're not doing it at the end of the animation
|
||
for (Torrent* torrent in torrents)
|
||
{
|
||
[torrent closeRemoveTorrent:deleteData];
|
||
}
|
||
}
|
||
}
|
||
|
||
- (void)removeNoDelete:(id)sender
|
||
{
|
||
[self removeTorrents:self.fTableView.selectedTorrents deleteData:NO];
|
||
}
|
||
|
||
- (void)removeDeleteData:(id)sender
|
||
{
|
||
[self removeTorrents:self.fTableView.selectedTorrents deleteData:YES];
|
||
}
|
||
|
||
- (void)clearCompleted:(id)sender
|
||
{
|
||
NSMutableArray<Torrent*>* torrents = [NSMutableArray array];
|
||
|
||
for (Torrent* torrent in self.fTorrents)
|
||
{
|
||
if (torrent.finishedSeeding)
|
||
{
|
||
[torrents addObject:torrent];
|
||
}
|
||
}
|
||
|
||
if ([self.fDefaults boolForKey:@"WarningRemoveCompleted"])
|
||
{
|
||
NSString *message, *info;
|
||
if (torrents.count == 1)
|
||
{
|
||
NSString* torrentName = torrents[0].name;
|
||
message = [NSString
|
||
stringWithFormat:NSLocalizedString(@"Are you sure you want to remove \"%@\" from the transfer list?", "Remove completed confirm panel -> title"),
|
||
torrentName];
|
||
|
||
info = NSLocalizedString(
|
||
@"Once removed, continuing the transfer will require the torrent file or magnet link.",
|
||
"Remove completed confirm panel -> message");
|
||
}
|
||
else
|
||
{
|
||
message = [NSString localizedStringWithFormat:NSLocalizedString(
|
||
@"Are you sure you want to remove %lu completed transfers from the transfer list?",
|
||
"Remove completed confirm panel -> title"),
|
||
torrents.count];
|
||
|
||
info = NSLocalizedString(
|
||
@"Once removed, continuing the transfers will require the torrent files or magnet links.",
|
||
"Remove completed confirm panel -> message");
|
||
}
|
||
|
||
NSAlert* alert = [[NSAlert alloc] init];
|
||
alert.messageText = message;
|
||
alert.informativeText = info;
|
||
alert.alertStyle = NSAlertStyleWarning;
|
||
[alert addButtonWithTitle:NSLocalizedString(@"Remove", "Remove completed confirm panel -> button")];
|
||
[alert addButtonWithTitle:NSLocalizedString(@"Cancel", "Remove completed confirm panel -> button")];
|
||
alert.showsSuppressionButton = YES;
|
||
|
||
NSInteger const returnCode = [alert runModal];
|
||
if (alert.suppressionButton.state)
|
||
{
|
||
[self.fDefaults setBool:NO forKey:@"WarningRemoveCompleted"];
|
||
}
|
||
|
||
if (returnCode != NSAlertFirstButtonReturn)
|
||
{
|
||
return;
|
||
}
|
||
}
|
||
|
||
[self confirmRemoveTorrents:torrents deleteData:NO];
|
||
}
|
||
|
||
- (void)moveDataFilesSelected:(id)sender
|
||
{
|
||
[self moveDataFiles:self.fTableView.selectedTorrents];
|
||
}
|
||
|
||
- (void)moveDataFiles:(NSArray<Torrent*>*)torrents
|
||
{
|
||
NSOpenPanel* panel = [NSOpenPanel openPanel];
|
||
panel.prompt = NSLocalizedString(@"Select", "Move torrent -> prompt");
|
||
panel.allowsMultipleSelection = NO;
|
||
panel.canChooseFiles = NO;
|
||
panel.canChooseDirectories = YES;
|
||
panel.canCreateDirectories = YES;
|
||
|
||
NSUInteger count = torrents.count;
|
||
if (count == 1)
|
||
{
|
||
panel.message = [NSString
|
||
stringWithFormat:NSLocalizedString(@"Select the new folder for \"%@\".", "Move torrent -> select destination folder"),
|
||
torrents[0].name];
|
||
}
|
||
else
|
||
{
|
||
panel.message = [NSString
|
||
localizedStringWithFormat:NSLocalizedString(@"Select the new folder for %lu data files.", "Move torrent -> select destination folder"),
|
||
count];
|
||
}
|
||
|
||
[panel beginSheetModalForWindow:self.fWindow completionHandler:^(NSInteger result) {
|
||
if (result == NSModalResponseOK)
|
||
{
|
||
for (Torrent* torrent in torrents)
|
||
{
|
||
[torrent moveTorrentDataFileTo:panel.URLs[0].path];
|
||
}
|
||
}
|
||
}];
|
||
}
|
||
|
||
- (void)copyTorrentFiles:(id)sender
|
||
{
|
||
[self copyTorrentFileForTorrents:[[NSMutableArray alloc] initWithArray:self.fTableView.selectedTorrents]];
|
||
}
|
||
|
||
- (void)copyTorrentFileForTorrents:(NSMutableArray<Torrent*>*)torrents
|
||
{
|
||
if (torrents.count == 0)
|
||
{
|
||
return;
|
||
}
|
||
|
||
Torrent* torrent = torrents[0];
|
||
|
||
if (!torrent.magnet && [NSFileManager.defaultManager fileExistsAtPath:torrent.torrentLocation])
|
||
{
|
||
NSSavePanel* panel = [NSSavePanel savePanel];
|
||
panel.allowedFileTypes = @[ @"org.bittorrent.torrent", @"torrent" ];
|
||
panel.extensionHidden = NO;
|
||
|
||
panel.nameFieldStringValue = torrent.name;
|
||
|
||
[panel beginSheetModalForWindow:self.fWindow completionHandler:^(NSInteger result) {
|
||
//copy torrent to new location with name of data file
|
||
if (result == NSModalResponseOK)
|
||
{
|
||
[torrent copyTorrentFileTo:panel.URL.path];
|
||
}
|
||
|
||
[torrents removeObjectAtIndex:0];
|
||
[self performSelectorOnMainThread:@selector(copyTorrentFileForTorrents:) withObject:torrents waitUntilDone:NO];
|
||
}];
|
||
}
|
||
else
|
||
{
|
||
if (!torrent.magnet)
|
||
{
|
||
NSAlert* alert = [[NSAlert alloc] init];
|
||
[alert addButtonWithTitle:NSLocalizedString(@"OK", "Torrent file copy alert -> button")];
|
||
alert.messageText = [NSString
|
||
stringWithFormat:NSLocalizedString(@"Copy of \"%@\" Cannot Be Created", "Torrent file copy alert -> title"),
|
||
torrent.name];
|
||
alert.informativeText = [NSString
|
||
stringWithFormat:NSLocalizedString(@"The torrent file (%@) cannot be found.", "Torrent file copy alert -> message"),
|
||
torrent.torrentLocation];
|
||
alert.alertStyle = NSAlertStyleWarning;
|
||
|
||
[alert runModal];
|
||
}
|
||
|
||
[torrents removeObjectAtIndex:0];
|
||
[self copyTorrentFileForTorrents:torrents];
|
||
}
|
||
}
|
||
|
||
- (void)copyMagnetLinks:(id)sender
|
||
{
|
||
[self.fTableView copy:sender];
|
||
}
|
||
|
||
- (void)revealFile:(id)sender
|
||
{
|
||
NSArray* selected = self.fTableView.selectedTorrents;
|
||
NSMutableArray* paths = [NSMutableArray arrayWithCapacity:selected.count];
|
||
for (Torrent* torrent in selected)
|
||
{
|
||
NSString* location = torrent.dataLocation;
|
||
if (location)
|
||
{
|
||
[paths addObject:[NSURL fileURLWithPath:location]];
|
||
}
|
||
}
|
||
|
||
if (paths.count > 0)
|
||
{
|
||
[NSWorkspace.sharedWorkspace activateFileViewerSelectingURLs:paths];
|
||
}
|
||
}
|
||
|
||
- (IBAction)renameSelected:(id)sender
|
||
{
|
||
NSArray* selected = self.fTableView.selectedTorrents;
|
||
NSAssert(selected.count == 1, @"1 transfer needs to be selected to rename, but %ld are selected", selected.count);
|
||
Torrent* torrent = selected[0];
|
||
|
||
[FileRenameSheetController presentSheetForTorrent:torrent modalForWindow:self.fWindow completionHandler:^(BOOL didRename) {
|
||
if (didRename)
|
||
{
|
||
dispatch_async(dispatch_get_main_queue(), ^{
|
||
[self fullUpdateUI];
|
||
|
||
[NSNotificationCenter.defaultCenter postNotificationName:@"ResetInspector" object:self
|
||
userInfo:@{ @"Torrent" : torrent }];
|
||
});
|
||
}
|
||
}];
|
||
}
|
||
|
||
- (void)announceSelectedTorrents:(id)sender
|
||
{
|
||
for (Torrent* torrent in self.fTableView.selectedTorrents)
|
||
{
|
||
if (torrent.canManualAnnounce)
|
||
{
|
||
[torrent manualAnnounce];
|
||
}
|
||
}
|
||
}
|
||
|
||
- (void)verifySelectedTorrents:(id)sender
|
||
{
|
||
[self verifyTorrents:self.fTableView.selectedTorrents];
|
||
}
|
||
|
||
- (void)verifyTorrents:(NSArray<Torrent*>*)torrents
|
||
{
|
||
for (Torrent* torrent in torrents)
|
||
{
|
||
[torrent resetCache];
|
||
}
|
||
|
||
[self applyFilter];
|
||
}
|
||
|
||
- (NSArray<Torrent*>*)selectedTorrents
|
||
{
|
||
return self.fTableView.selectedTorrents;
|
||
}
|
||
|
||
- (void)showPreferenceWindow:(id)sender
|
||
{
|
||
NSWindow* window = _prefsController.window;
|
||
if (!window.visible)
|
||
{
|
||
[window center];
|
||
}
|
||
|
||
[window makeKeyAndOrderFront:nil];
|
||
}
|
||
|
||
- (void)showAboutWindow:(id)sender
|
||
{
|
||
[AboutWindowController.aboutController showWindow:nil];
|
||
}
|
||
|
||
- (void)showInfo:(id)sender
|
||
{
|
||
if (self.fInfoController.window.visible)
|
||
{
|
||
[self.fInfoController close];
|
||
}
|
||
else
|
||
{
|
||
[self.fInfoController updateInfoStats];
|
||
[self.fInfoController.window orderFront:nil];
|
||
|
||
if (self.fInfoController.canQuickLook && [QLPreviewPanel sharedPreviewPanelExists] &&
|
||
[QLPreviewPanel sharedPreviewPanel].visible)
|
||
{
|
||
[[QLPreviewPanel sharedPreviewPanel] reloadData];
|
||
}
|
||
}
|
||
|
||
[self.fWindow.toolbar validateVisibleItems];
|
||
}
|
||
|
||
- (void)resetInfo
|
||
{
|
||
[self.fInfoController setInfoForTorrents:self.fTableView.selectedTorrents];
|
||
|
||
if ([QLPreviewPanel sharedPreviewPanelExists] && [QLPreviewPanel sharedPreviewPanel].visible)
|
||
{
|
||
[[QLPreviewPanel sharedPreviewPanel] reloadData];
|
||
}
|
||
}
|
||
|
||
- (void)setInfoTab:(id)sender
|
||
{
|
||
if (sender == self.fNextInfoTabItem)
|
||
{
|
||
[self.fInfoController setNextTab];
|
||
}
|
||
else
|
||
{
|
||
[self.fInfoController setPreviousTab];
|
||
}
|
||
}
|
||
|
||
- (MessageWindowController*)messageWindowController
|
||
{
|
||
if (!self.fMessageController)
|
||
{
|
||
self.fMessageController = [[MessageWindowController alloc] init];
|
||
}
|
||
|
||
return self.fMessageController;
|
||
}
|
||
|
||
- (void)showMessageWindow:(id)sender
|
||
{
|
||
[self.messageWindowController showWindow:nil];
|
||
}
|
||
|
||
- (void)showStatsWindow:(id)sender
|
||
{
|
||
[StatsWindowController.statsWindow showWindow:nil];
|
||
}
|
||
|
||
- (void)updateUI
|
||
{
|
||
CGFloat dlRate = 0.0, ulRate = 0.0;
|
||
BOOL anyCompleted = NO;
|
||
BOOL anyActive = NO;
|
||
|
||
{
|
||
// avoid having to wait for the same lock multiple times in the same operation
|
||
auto const lock = tr_sessionLock(self.sessionHandle);
|
||
for (Torrent* torrent in self.fTorrents)
|
||
{
|
||
[torrent update];
|
||
|
||
//pull the upload and download speeds - most consistent by using current stats
|
||
dlRate += torrent.downloadRate;
|
||
ulRate += torrent.uploadRate;
|
||
|
||
anyCompleted |= torrent.finishedSeeding;
|
||
anyActive |= torrent.active && !torrent.stalled && !torrent.error;
|
||
}
|
||
}
|
||
|
||
PowerManager.shared.shouldPreventSleep = anyActive && [self.fDefaults boolForKey:@"SleepPrevent"];
|
||
|
||
if (!NSApp.hidden)
|
||
{
|
||
if (self.fWindow.visible)
|
||
{
|
||
[self sortTorrentsAndIncludeQueueOrder:NO];
|
||
|
||
[self.fStatusBar updateWithDownload:dlRate upload:ulRate];
|
||
|
||
self.fClearCompletedButton.hidden = !anyCompleted;
|
||
}
|
||
|
||
//update non-constant parts of info window
|
||
if (self.fInfoController.window.visible)
|
||
{
|
||
[self.fInfoController updateInfoStats];
|
||
}
|
||
|
||
[self.fTableView reloadVisibleRows];
|
||
}
|
||
|
||
//badge dock
|
||
[self.fBadger updateBadgeWithDownload:dlRate upload:ulRate];
|
||
}
|
||
|
||
#warning can this be removed or refined?
|
||
- (void)fullUpdateUI
|
||
{
|
||
[self updateUI];
|
||
[self applyFilter];
|
||
[self.fWindow.toolbar validateVisibleItems];
|
||
[self updateTorrentHistory];
|
||
}
|
||
|
||
- (void)setBottomCountText:(BOOL)filtering
|
||
{
|
||
NSString* totalTorrentsString;
|
||
NSUInteger totalCount = self.fTorrents.count;
|
||
if (totalCount != 1)
|
||
{
|
||
totalTorrentsString = [NSString localizedStringWithFormat:NSLocalizedString(@"%lu transfers", "Status bar transfer count"), totalCount];
|
||
}
|
||
else
|
||
{
|
||
totalTorrentsString = NSLocalizedString(@"1 transfer", "Status bar transfer count");
|
||
}
|
||
|
||
if (filtering)
|
||
{
|
||
NSUInteger count = self.fTableView.numberOfRows; //have to factor in collapsed rows
|
||
if (count > 0 && ![self.fDisplayedTorrents[0] isKindOfClass:[Torrent class]])
|
||
{
|
||
count -= self.fDisplayedTorrents.count;
|
||
}
|
||
|
||
totalTorrentsString = [NSString stringWithFormat:NSLocalizedString(@"%@ of %@", "Status bar transfer count"),
|
||
[NSString localizedStringWithFormat:@"%lu", count],
|
||
totalTorrentsString];
|
||
}
|
||
|
||
self.fTotalTorrentsField.stringValue = totalTorrentsString;
|
||
}
|
||
|
||
#pragma mark - UNUserNotificationCenterDelegate
|
||
|
||
- (void)userNotificationCenter:(UNUserNotificationCenter*)center
|
||
willPresentNotification:(UNNotification*)notification
|
||
withCompletionHandler:(void (^)(UNNotificationPresentationOptions))completionHandler
|
||
{
|
||
completionHandler(-1);
|
||
}
|
||
|
||
- (void)userNotificationCenter:(UNUserNotificationCenter*)center
|
||
didReceiveNotificationResponse:(UNNotificationResponse*)response
|
||
withCompletionHandler:(void (^)(void))completionHandler
|
||
{
|
||
if (!response.notification.request.content.userInfo.count)
|
||
{
|
||
completionHandler();
|
||
return;
|
||
}
|
||
|
||
if ([response.actionIdentifier isEqualToString:UNNotificationDefaultActionIdentifier])
|
||
{
|
||
[self didActivateNotificationByDefaultActionWithUserInfo:response.notification.request.content.userInfo];
|
||
}
|
||
else if ([response.actionIdentifier isEqualToString:@"actionShow"])
|
||
{
|
||
[self didActivateNotificationByActionShowWithUserInfo:response.notification.request.content.userInfo];
|
||
}
|
||
completionHandler();
|
||
}
|
||
|
||
- (void)didActivateNotificationByActionShowWithUserInfo:(NSDictionary<NSString*, id>*)userInfo
|
||
{
|
||
Torrent* torrent = [self torrentForHash:userInfo[@"Hash"]];
|
||
NSString* location = torrent.dataLocation;
|
||
if (!location)
|
||
{
|
||
location = userInfo[@"Location"];
|
||
}
|
||
if (location)
|
||
{
|
||
[NSWorkspace.sharedWorkspace activateFileViewerSelectingURLs:@[ [NSURL fileURLWithPath:location] ]];
|
||
}
|
||
}
|
||
|
||
- (void)didActivateNotificationByDefaultActionWithUserInfo:(NSDictionary<NSString*, id>*)userInfo
|
||
{
|
||
Torrent* torrent = [self torrentForHash:userInfo[@"Hash"]];
|
||
if (!torrent)
|
||
{
|
||
return;
|
||
}
|
||
//select in the table - first see if it's already shown
|
||
NSInteger row = [self.fTableView rowForItem:torrent];
|
||
if (row == -1)
|
||
{
|
||
//if it's not shown, see if it's in a collapsed row
|
||
if ([self.fDefaults boolForKey:@"SortByGroup"])
|
||
{
|
||
__block TorrentGroup* parent = nil;
|
||
[self.fDisplayedTorrents enumerateObjectsWithOptions:NSEnumerationConcurrent
|
||
usingBlock:^(TorrentGroup* group, NSUInteger /*idx*/, BOOL* stop) {
|
||
if ([group.torrents containsObject:torrent])
|
||
{
|
||
parent = group;
|
||
*stop = YES;
|
||
}
|
||
}];
|
||
if (parent)
|
||
{
|
||
[[self.fTableView animator] expandItem:parent];
|
||
row = [self.fTableView rowForItem:torrent];
|
||
}
|
||
}
|
||
|
||
if (row == -1)
|
||
{
|
||
//not found - must be filtering
|
||
NSAssert([self.fDefaults boolForKey:@"FilterBar"], @"expected the filter to be enabled");
|
||
[self.fFilterBar reset];
|
||
|
||
row = [self.fTableView rowForItem:torrent];
|
||
|
||
//if it's not shown, it has to be in a collapsed row...again
|
||
if ([self.fDefaults boolForKey:@"SortByGroup"])
|
||
{
|
||
__block TorrentGroup* parent = nil;
|
||
[self.fDisplayedTorrents enumerateObjectsWithOptions:NSEnumerationConcurrent
|
||
usingBlock:^(TorrentGroup* group, NSUInteger /*idx*/, BOOL* stop) {
|
||
if ([group.torrents containsObject:torrent])
|
||
{
|
||
parent = group;
|
||
*stop = YES;
|
||
}
|
||
}];
|
||
if (parent)
|
||
{
|
||
[[self.fTableView animator] expandItem:parent];
|
||
row = [self.fTableView rowForItem:torrent];
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
NSAssert1(row != -1, @"expected a row to be found for torrent %@", torrent);
|
||
|
||
[self showMainWindow:nil];
|
||
[self.fTableView selectAndScrollToRow:row];
|
||
}
|
||
|
||
#pragma mark -
|
||
|
||
- (Torrent*)torrentForHash:(NSString*)hash
|
||
{
|
||
NSParameterAssert(hash != nil);
|
||
|
||
__block Torrent* torrent = nil;
|
||
[self.fTorrents enumerateObjectsWithOptions:NSEnumerationConcurrent usingBlock:^(Torrent* obj, NSUInteger /*idx*/, BOOL* stop) {
|
||
if ([obj.hashString isEqualToString:hash])
|
||
{
|
||
torrent = obj;
|
||
*stop = YES;
|
||
}
|
||
}];
|
||
return torrent;
|
||
}
|
||
|
||
- (void)torrentFinishedDownloading:(NSNotification*)notification
|
||
{
|
||
Torrent* torrent = notification.object;
|
||
|
||
if ([notification.userInfo[@"WasRunning"] boolValue])
|
||
{
|
||
if (!self.fSoundPlaying && [self.fDefaults boolForKey:@"PlayDownloadSound"])
|
||
{
|
||
NSSound* sound;
|
||
if ((sound = [NSSound soundNamed:[self.fDefaults stringForKey:@"DownloadSound"]]))
|
||
{
|
||
sound.delegate = self;
|
||
self.fSoundPlaying = YES;
|
||
[sound play];
|
||
}
|
||
}
|
||
|
||
NSString* title = NSLocalizedString(@"Download Complete", "notification title");
|
||
NSString* body = torrent.name;
|
||
NSString* location = torrent.dataLocation;
|
||
NSMutableDictionary* userInfo = [NSMutableDictionary dictionaryWithObject:torrent.hashString forKey:@"Hash"];
|
||
if (location)
|
||
{
|
||
userInfo[@"Location"] = location;
|
||
}
|
||
|
||
NSString* identifier = [@"Download Complete " stringByAppendingString:torrent.hashString];
|
||
UNMutableNotificationContent* content = [UNMutableNotificationContent new];
|
||
content.title = title;
|
||
content.body = body;
|
||
content.categoryIdentifier = @"categoryShow";
|
||
content.userInfo = userInfo;
|
||
|
||
UNNotificationRequest* request = [UNNotificationRequest requestWithIdentifier:identifier content:content trigger:nil];
|
||
[UNUserNotificationCenter.currentNotificationCenter addNotificationRequest:request withCompletionHandler:nil];
|
||
|
||
if (!self.fWindow.mainWindow)
|
||
{
|
||
[self.fBadger addCompletedTorrent:torrent];
|
||
}
|
||
|
||
//bounce download stack
|
||
[NSDistributedNotificationCenter.defaultCenter postNotificationName:@"com.apple.DownloadFileFinished"
|
||
object:torrent.dataLocation];
|
||
}
|
||
|
||
[self fullUpdateUI];
|
||
}
|
||
|
||
- (void)torrentRestartedDownloading:(NSNotification*)notification
|
||
{
|
||
[self fullUpdateUI];
|
||
}
|
||
|
||
- (void)torrentFinishedSeeding:(NSNotification*)notification
|
||
{
|
||
Torrent* torrent = notification.object;
|
||
|
||
if (!self.fSoundPlaying && [self.fDefaults boolForKey:@"PlaySeedingSound"])
|
||
{
|
||
NSSound* sound;
|
||
if ((sound = [NSSound soundNamed:[self.fDefaults stringForKey:@"SeedingSound"]]))
|
||
{
|
||
sound.delegate = self;
|
||
self.fSoundPlaying = YES;
|
||
[sound play];
|
||
}
|
||
}
|
||
|
||
NSString* title = NSLocalizedString(@"Seeding Complete", "notification title");
|
||
NSString* body = torrent.name;
|
||
NSString* location = torrent.dataLocation;
|
||
NSMutableDictionary* userInfo = [NSMutableDictionary dictionaryWithObject:torrent.hashString forKey:@"Hash"];
|
||
if (location)
|
||
{
|
||
userInfo[@"Location"] = location;
|
||
}
|
||
|
||
NSString* identifier = [@"Seeding Complete " stringByAppendingString:torrent.hashString];
|
||
UNMutableNotificationContent* content = [UNMutableNotificationContent new];
|
||
content.title = title;
|
||
content.body = body;
|
||
content.categoryIdentifier = @"categoryShow";
|
||
content.userInfo = userInfo;
|
||
|
||
UNNotificationRequest* request = [UNNotificationRequest requestWithIdentifier:identifier content:content trigger:nil];
|
||
[UNUserNotificationCenter.currentNotificationCenter addNotificationRequest:request withCompletionHandler:nil];
|
||
|
||
//removing from the list calls fullUpdateUI
|
||
if (torrent.removeWhenFinishSeeding)
|
||
{
|
||
[self confirmRemoveTorrents:@[ torrent ] deleteData:NO];
|
||
}
|
||
else
|
||
{
|
||
if (!self.fWindow.mainWindow)
|
||
{
|
||
[self.fBadger addCompletedTorrent:torrent];
|
||
}
|
||
|
||
[self fullUpdateUI];
|
||
|
||
if ([self.fTableView.selectedTorrents containsObject:torrent])
|
||
{
|
||
[self.fInfoController updateInfoStats];
|
||
[self.fInfoController updateOptions];
|
||
}
|
||
}
|
||
}
|
||
|
||
- (void)updateTorrentHistory
|
||
{
|
||
NSMutableArray* history = [NSMutableArray arrayWithCapacity:self.fTorrents.count];
|
||
|
||
for (Torrent* torrent in self.fTorrents)
|
||
{
|
||
[history addObject:torrent.history];
|
||
self.fTorrentHashes[torrent.hashString] = torrent;
|
||
}
|
||
|
||
NSString* historyFile = [self.fConfigDirectory stringByAppendingPathComponent:kTransferPlist];
|
||
[history writeToFile:historyFile atomically:YES];
|
||
}
|
||
|
||
- (void)setSort:(id)sender
|
||
{
|
||
SortType sortType;
|
||
NSMenuItem* senderMenuItem = sender;
|
||
switch (senderMenuItem.tag)
|
||
{
|
||
case SortTagOrder:
|
||
sortType = SortTypeOrder;
|
||
[self.fDefaults setBool:NO forKey:@"SortReverse"];
|
||
break;
|
||
case SortTagDate:
|
||
sortType = SortTypeDate;
|
||
break;
|
||
case SortTagName:
|
||
sortType = SortTypeName;
|
||
break;
|
||
case SortTagProgress:
|
||
sortType = SortTypeProgress;
|
||
break;
|
||
case SortTagState:
|
||
sortType = SortTypeState;
|
||
break;
|
||
case SortTagTracker:
|
||
sortType = SortTypeTracker;
|
||
break;
|
||
case SortTagActivity:
|
||
sortType = SortTypeActivity;
|
||
break;
|
||
case SortTagSize:
|
||
sortType = SortTypeSize;
|
||
break;
|
||
case SortTagETA:
|
||
sortType = SortTypeETA;
|
||
break;
|
||
default:
|
||
NSAssert1(NO, @"Unknown sort tag received: %ld", senderMenuItem.tag);
|
||
return;
|
||
}
|
||
|
||
[self.fDefaults setObject:sortType forKey:@"Sort"];
|
||
|
||
[self sortTorrentsAndIncludeQueueOrder:YES];
|
||
}
|
||
|
||
- (void)setSortByGroup:(id)sender
|
||
{
|
||
BOOL sortByGroup = ![self.fDefaults boolForKey:@"SortByGroup"];
|
||
[self.fDefaults setBool:sortByGroup forKey:@"SortByGroup"];
|
||
|
||
[self applyFilter];
|
||
}
|
||
|
||
- (void)setSortReverse:(id)sender
|
||
{
|
||
BOOL const setReverse = ((NSMenuItem*)sender).tag == SortOrderTagDescending;
|
||
if (setReverse != [self.fDefaults boolForKey:@"SortReverse"])
|
||
{
|
||
[self.fDefaults setBool:setReverse forKey:@"SortReverse"];
|
||
[self sortTorrentsAndIncludeQueueOrder:NO];
|
||
}
|
||
}
|
||
|
||
- (void)sortTorrentsAndIncludeQueueOrder:(BOOL)includeQueueOrder
|
||
{
|
||
//actually sort
|
||
[self sortTorrentsCallUpdates:YES includeQueueOrder:includeQueueOrder];
|
||
self.fTableView.needsDisplay = YES;
|
||
}
|
||
|
||
- (void)sortTorrentsCallUpdates:(BOOL)callUpdates includeQueueOrder:(BOOL)includeQueueOrder
|
||
{
|
||
BOOL const asc = ![self.fDefaults boolForKey:@"SortReverse"];
|
||
|
||
NSArray* descriptors;
|
||
NSSortDescriptor* nameDescriptor = [NSSortDescriptor sortDescriptorWithKey:@"name" ascending:asc
|
||
selector:@selector(localizedStandardCompare:)];
|
||
|
||
NSString* sortType = [self.fDefaults stringForKey:@"Sort"];
|
||
if ([sortType isEqualToString:SortTypeState])
|
||
{
|
||
NSSortDescriptor* stateDescriptor = [NSSortDescriptor sortDescriptorWithKey:@"stateSortKey" ascending:!asc];
|
||
NSSortDescriptor* progressDescriptor = [NSSortDescriptor sortDescriptorWithKey:@"progress" ascending:!asc];
|
||
NSSortDescriptor* ratioDescriptor = [NSSortDescriptor sortDescriptorWithKey:@"ratio" ascending:!asc];
|
||
|
||
descriptors = @[ stateDescriptor, progressDescriptor, ratioDescriptor, nameDescriptor ];
|
||
}
|
||
else if ([sortType isEqualToString:SortTypeProgress])
|
||
{
|
||
NSSortDescriptor* progressDescriptor = [NSSortDescriptor sortDescriptorWithKey:@"progress" ascending:asc];
|
||
NSSortDescriptor* ratioProgressDescriptor = [NSSortDescriptor sortDescriptorWithKey:@"progressStopRatio" ascending:asc];
|
||
NSSortDescriptor* ratioDescriptor = [NSSortDescriptor sortDescriptorWithKey:@"ratio" ascending:asc];
|
||
|
||
descriptors = @[ progressDescriptor, ratioProgressDescriptor, ratioDescriptor, nameDescriptor ];
|
||
}
|
||
else if ([sortType isEqualToString:SortTypeETA])
|
||
{
|
||
NSSortDescriptor* etaDescriptor = [NSSortDescriptor sortDescriptorWithKey:@"eta" ascending:asc];
|
||
// falling back on sort by progress
|
||
NSSortDescriptor* progressDescriptor = [NSSortDescriptor sortDescriptorWithKey:@"progress" ascending:asc];
|
||
NSSortDescriptor* ratioProgressDescriptor = [NSSortDescriptor sortDescriptorWithKey:@"progressStopRatio" ascending:asc];
|
||
NSSortDescriptor* ratioDescriptor = [NSSortDescriptor sortDescriptorWithKey:@"ratio" ascending:asc];
|
||
|
||
descriptors = @[ etaDescriptor, progressDescriptor, ratioProgressDescriptor, ratioDescriptor, nameDescriptor ];
|
||
}
|
||
else if ([sortType isEqualToString:SortTypeTracker])
|
||
{
|
||
NSSortDescriptor* trackerDescriptor = [NSSortDescriptor sortDescriptorWithKey:@"trackerSortKey" ascending:asc
|
||
selector:@selector(localizedCaseInsensitiveCompare:)];
|
||
|
||
descriptors = @[ trackerDescriptor, nameDescriptor ];
|
||
}
|
||
else if ([sortType isEqualToString:SortTypeActivity])
|
||
{
|
||
NSSortDescriptor* rateDescriptor = [NSSortDescriptor sortDescriptorWithKey:@"totalRate" ascending:asc];
|
||
NSSortDescriptor* activityDescriptor = [NSSortDescriptor sortDescriptorWithKey:@"dateActivityOrAdd" ascending:asc];
|
||
|
||
descriptors = @[ rateDescriptor, activityDescriptor, nameDescriptor ];
|
||
}
|
||
else if ([sortType isEqualToString:SortTypeDate])
|
||
{
|
||
NSSortDescriptor* dateDescriptor = [NSSortDescriptor sortDescriptorWithKey:@"dateAdded" ascending:asc];
|
||
|
||
descriptors = @[ dateDescriptor, nameDescriptor ];
|
||
}
|
||
else if ([sortType isEqualToString:SortTypeSize])
|
||
{
|
||
NSSortDescriptor* sizeDescriptor = [NSSortDescriptor sortDescriptorWithKey:@"totalSizeSelected" ascending:asc];
|
||
|
||
descriptors = @[ sizeDescriptor, nameDescriptor ];
|
||
}
|
||
else if ([sortType isEqualToString:SortTypeName])
|
||
{
|
||
descriptors = @[ nameDescriptor ];
|
||
}
|
||
else
|
||
{
|
||
NSAssert1([sortType isEqualToString:SortTypeOrder], @"Unknown sort type received: %@", sortType);
|
||
|
||
if (!includeQueueOrder)
|
||
{
|
||
return;
|
||
}
|
||
|
||
NSSortDescriptor* orderDescriptor = [NSSortDescriptor sortDescriptorWithKey:@"queuePosition" ascending:asc];
|
||
|
||
descriptors = @[ orderDescriptor ];
|
||
}
|
||
|
||
BOOL beganTableUpdate = !callUpdates;
|
||
|
||
//actually sort
|
||
if ([self.fDefaults boolForKey:@"SortByGroup"])
|
||
{
|
||
for (TorrentGroup* group in self.fDisplayedTorrents)
|
||
{
|
||
[self rearrangeTorrentTableArray:group.torrents forParent:group withSortDescriptors:descriptors
|
||
beganTableUpdate:&beganTableUpdate];
|
||
}
|
||
}
|
||
else
|
||
{
|
||
[self rearrangeTorrentTableArray:self.fDisplayedTorrents forParent:nil withSortDescriptors:descriptors
|
||
beganTableUpdate:&beganTableUpdate];
|
||
}
|
||
|
||
if (beganTableUpdate && callUpdates)
|
||
{
|
||
[self.fTableView endUpdates];
|
||
}
|
||
}
|
||
|
||
#warning redo so that we search a copy once again (best explained by changing sorting from ascending to descending)
|
||
- (void)rearrangeTorrentTableArray:(NSMutableArray*)rearrangeArray
|
||
forParent:parent
|
||
withSortDescriptors:(NSArray*)descriptors
|
||
beganTableUpdate:(BOOL*)beganTableUpdate
|
||
{
|
||
for (NSUInteger currentIndex = 1; currentIndex < rearrangeArray.count; ++currentIndex)
|
||
{
|
||
//manually do the sorting in-place
|
||
NSUInteger const insertIndex = [rearrangeArray indexOfObject:rearrangeArray[currentIndex]
|
||
inSortedRange:NSMakeRange(0, currentIndex)
|
||
options:(NSBinarySearchingInsertionIndex | NSBinarySearchingLastEqual)
|
||
usingComparator:^NSComparisonResult(id obj1, id obj2) {
|
||
for (NSSortDescriptor* descriptor in descriptors)
|
||
{
|
||
NSComparisonResult const result = [descriptor compareObject:obj1
|
||
toObject:obj2];
|
||
if (result != NSOrderedSame)
|
||
{
|
||
return result;
|
||
}
|
||
}
|
||
|
||
return NSOrderedSame;
|
||
}];
|
||
|
||
if (insertIndex != currentIndex)
|
||
{
|
||
if (!*beganTableUpdate)
|
||
{
|
||
*beganTableUpdate = YES;
|
||
[self.fTableView beginUpdates];
|
||
}
|
||
|
||
[rearrangeArray moveObjectAtIndex:currentIndex toIndex:insertIndex];
|
||
[self.fTableView moveItemAtIndex:currentIndex inParent:parent toIndex:insertIndex inParent:parent];
|
||
}
|
||
}
|
||
|
||
NSAssert2(
|
||
[rearrangeArray isEqualToArray:[rearrangeArray sortedArrayUsingDescriptors:descriptors]],
|
||
@"Torrent rearranging didn't work! %@ %@",
|
||
rearrangeArray,
|
||
[rearrangeArray sortedArrayUsingDescriptors:descriptors]);
|
||
}
|
||
|
||
- (void)applyFilter
|
||
{
|
||
NSString* filterType = [self.fDefaults stringForKey:@"Filter"];
|
||
BOOL filterActive = NO, filterDownload = NO, filterSeed = NO, filterPause = NO, filterError = NO, filterStatus = YES;
|
||
if ([filterType isEqualToString:FilterTypeActive])
|
||
{
|
||
filterActive = YES;
|
||
}
|
||
else if ([filterType isEqualToString:FilterTypeDownload])
|
||
{
|
||
filterDownload = YES;
|
||
}
|
||
else if ([filterType isEqualToString:FilterTypeSeed])
|
||
{
|
||
filterSeed = YES;
|
||
}
|
||
else if ([filterType isEqualToString:FilterTypePause])
|
||
{
|
||
filterPause = YES;
|
||
}
|
||
else if ([filterType isEqualToString:FilterTypeError])
|
||
{
|
||
filterError = YES;
|
||
}
|
||
else
|
||
{
|
||
filterStatus = NO;
|
||
}
|
||
|
||
NSInteger const groupFilterValue = [self.fDefaults integerForKey:@"FilterGroup"];
|
||
BOOL const filterGroup = groupFilterValue != kGroupFilterAllTag;
|
||
|
||
NSArray<NSString*>* searchStrings = self.fFilterBar.searchStrings;
|
||
if (searchStrings && searchStrings.count == 0)
|
||
{
|
||
searchStrings = nil;
|
||
}
|
||
BOOL const filterTracker = searchStrings && [[self.fDefaults stringForKey:@"FilterSearchType"] isEqualToString:FilterSearchTypeTracker];
|
||
|
||
std::atomic<int32_t> active{ 0 }, downloading{ 0 }, seeding{ 0 }, paused{ 0 }, error{ 0 };
|
||
// Pointers to be captured by Obj-C Block as const*
|
||
auto* activeRef = &active;
|
||
auto* downloadingRef = &downloading;
|
||
auto* seedingRef = &seeding;
|
||
auto* pausedRef = &paused;
|
||
auto* errorRef = &error;
|
||
//filter & get counts of each type
|
||
NSIndexSet* indexesOfNonFilteredTorrents = [self.fTorrents
|
||
indexesOfObjectsWithOptions:NSEnumerationConcurrent passingTest:^BOOL(Torrent* torrent, NSUInteger /*torrentIdx*/, BOOL* /*stopTorrentsEnumeration*/) {
|
||
//check status
|
||
if (torrent.active && !torrent.checkingWaiting)
|
||
{
|
||
BOOL const isActive = torrent.transmitting;
|
||
if (isActive)
|
||
{
|
||
std::atomic_fetch_add_explicit(activeRef, 1, std::memory_order_relaxed);
|
||
}
|
||
|
||
if (torrent.seeding)
|
||
{
|
||
std::atomic_fetch_add_explicit(seedingRef, 1, std::memory_order_relaxed);
|
||
if (filterStatus && !((filterActive && isActive) || filterSeed))
|
||
{
|
||
return NO;
|
||
}
|
||
}
|
||
else
|
||
{
|
||
std::atomic_fetch_add_explicit(downloadingRef, 1, std::memory_order_relaxed);
|
||
if (filterStatus && !((filterActive && isActive) || filterDownload))
|
||
{
|
||
return NO;
|
||
}
|
||
}
|
||
}
|
||
else if (torrent.error)
|
||
{
|
||
std::atomic_fetch_add_explicit(errorRef, 1, std::memory_order_relaxed);
|
||
if (filterStatus && !filterError)
|
||
{
|
||
return NO;
|
||
}
|
||
}
|
||
else
|
||
{
|
||
std::atomic_fetch_add_explicit(pausedRef, 1, std::memory_order_relaxed);
|
||
if (filterStatus && !filterPause)
|
||
{
|
||
return NO;
|
||
}
|
||
}
|
||
|
||
//checkGroup
|
||
if (filterGroup)
|
||
if (torrent.groupValue != groupFilterValue)
|
||
{
|
||
return NO;
|
||
}
|
||
|
||
//check text field
|
||
if (searchStrings)
|
||
{
|
||
__block BOOL removeTextField = NO;
|
||
if (filterTracker)
|
||
{
|
||
NSArray<NSString*>* trackers = torrent.allTrackersFlat;
|
||
|
||
//to count, we need each string in at least 1 tracker
|
||
[searchStrings
|
||
enumerateObjectsWithOptions:NSEnumerationConcurrent usingBlock:^(NSString* searchString, NSUInteger /*idx*/, BOOL* stop) {
|
||
__block BOOL found = NO;
|
||
[trackers enumerateObjectsWithOptions:NSEnumerationConcurrent
|
||
usingBlock:^(NSString* tracker, NSUInteger /*trackerIdx*/, BOOL* stopEnumerateTrackers) {
|
||
if ([tracker rangeOfString:searchString
|
||
options:(NSCaseInsensitiveSearch | NSDiacriticInsensitiveSearch)]
|
||
.location != NSNotFound)
|
||
{
|
||
found = YES;
|
||
*stopEnumerateTrackers = YES;
|
||
}
|
||
}];
|
||
if (!found)
|
||
{
|
||
removeTextField = YES;
|
||
*stop = YES;
|
||
}
|
||
}];
|
||
}
|
||
else
|
||
{
|
||
[searchStrings enumerateObjectsWithOptions:NSEnumerationConcurrent
|
||
usingBlock:^(NSString* searchString, NSUInteger /*idx*/, BOOL* stop) {
|
||
if ([torrent.name rangeOfString:searchString
|
||
options:(NSCaseInsensitiveSearch | NSDiacriticInsensitiveSearch)]
|
||
.location == NSNotFound)
|
||
{
|
||
removeTextField = YES;
|
||
*stop = YES;
|
||
}
|
||
}];
|
||
}
|
||
|
||
if (removeTextField)
|
||
{
|
||
return NO;
|
||
}
|
||
}
|
||
|
||
return YES;
|
||
}];
|
||
|
||
NSArray<Torrent*>* allTorrents = [self.fTorrents objectsAtIndexes:indexesOfNonFilteredTorrents];
|
||
|
||
//set button tooltips
|
||
if (self.fFilterBar)
|
||
{
|
||
[self.fFilterBar setCountAll:self.fTorrents.count active:active.load() downloading:downloading.load()
|
||
seeding:seeding.load()
|
||
paused:paused.load()
|
||
error:error.load()];
|
||
}
|
||
|
||
//if either the previous or current lists are blank, set its value to the other
|
||
BOOL const groupRows = allTorrents.count > 0 ?
|
||
[self.fDefaults boolForKey:@"SortByGroup"] :
|
||
(self.fDisplayedTorrents.count > 0 && [self.fDisplayedTorrents[0] isKindOfClass:[TorrentGroup class]]);
|
||
BOOL const wasGroupRows = self.fDisplayedTorrents.count > 0 ? [self.fDisplayedTorrents[0] isKindOfClass:[TorrentGroup class]] : groupRows;
|
||
|
||
#warning could probably be merged with later code somehow
|
||
//clear display cache for not-shown torrents
|
||
if (self.fDisplayedTorrents.count > 0)
|
||
{
|
||
//for each torrent, removes the previous piece info if it's not in allTorrents, and keeps track of which torrents we already found in allTorrents
|
||
void (^removePreviousFinishedPieces)(id, NSUInteger, BOOL*) = ^(Torrent* torrent, NSUInteger /*idx*/, BOOL* /*stop*/) {
|
||
//we used to keep track of which torrents we already found in allTorrents, but it wasn't safe for concurrent enumeration
|
||
if (![allTorrents containsObject:torrent])
|
||
{
|
||
torrent.previousFinishedPieces = nil;
|
||
}
|
||
};
|
||
|
||
if (wasGroupRows)
|
||
{
|
||
[self.fDisplayedTorrents
|
||
enumerateObjectsWithOptions:NSEnumerationConcurrent usingBlock:^(id obj, NSUInteger /*idx*/, BOOL* /*stop*/) {
|
||
[((TorrentGroup*)obj).torrents enumerateObjectsWithOptions:NSEnumerationConcurrent
|
||
usingBlock:removePreviousFinishedPieces];
|
||
}];
|
||
}
|
||
else
|
||
{
|
||
[self.fDisplayedTorrents enumerateObjectsWithOptions:NSEnumerationConcurrent usingBlock:removePreviousFinishedPieces];
|
||
}
|
||
}
|
||
|
||
BOOL beganUpdates = NO;
|
||
|
||
//don't animate torrents when first launching
|
||
static dispatch_once_t onceToken;
|
||
dispatch_once(&onceToken, ^{
|
||
NSAnimationContext.currentContext.duration = 0;
|
||
});
|
||
[NSAnimationContext beginGrouping];
|
||
|
||
//add/remove torrents (and rearrange for groups), one by one
|
||
if (!groupRows && !wasGroupRows)
|
||
{
|
||
NSMutableIndexSet* addIndexes = [NSMutableIndexSet indexSet];
|
||
NSMutableIndexSet* removePreviousIndexes = [NSMutableIndexSet
|
||
indexSetWithIndexesInRange:NSMakeRange(0, self.fDisplayedTorrents.count)];
|
||
|
||
//for each of the torrents to add, find if it already exists (and keep track of those we've already added & those we need to remove)
|
||
[allTorrents enumerateObjectsWithOptions:0 usingBlock:^(Torrent* obj, NSUInteger previousIndex, BOOL* /*stopEnumerate*/) {
|
||
NSUInteger const currentIndex = [self.fDisplayedTorrents
|
||
indexOfObjectAtIndexes:removePreviousIndexes
|
||
options:NSEnumerationConcurrent
|
||
passingTest:^BOOL(id objDisplay, NSUInteger /*idx*/, BOOL* /*stop*/) {
|
||
return obj == objDisplay;
|
||
}];
|
||
if (currentIndex == NSNotFound)
|
||
{
|
||
[addIndexes addIndex:previousIndex];
|
||
}
|
||
else
|
||
{
|
||
[removePreviousIndexes removeIndex:currentIndex];
|
||
}
|
||
}];
|
||
|
||
if (addIndexes.count > 0 || removePreviousIndexes.count > 0)
|
||
{
|
||
beganUpdates = YES;
|
||
[self.fTableView beginUpdates];
|
||
|
||
//remove torrents we didn't find
|
||
if (removePreviousIndexes.count > 0)
|
||
{
|
||
[self.fDisplayedTorrents removeObjectsAtIndexes:removePreviousIndexes];
|
||
[self.fTableView removeItemsAtIndexes:removePreviousIndexes inParent:nil withAnimation:NSTableViewAnimationSlideDown];
|
||
}
|
||
|
||
//add new torrents
|
||
if (addIndexes.count > 0)
|
||
{
|
||
//slide new torrents in differently
|
||
if (self.fAddingTransfers)
|
||
{
|
||
NSIndexSet* newAddIndexes = [allTorrents
|
||
indexesOfObjectsAtIndexes:addIndexes
|
||
options:NSEnumerationConcurrent
|
||
passingTest:^BOOL(Torrent* obj, NSUInteger /*idx*/, BOOL* /*stop*/) {
|
||
return [self.fAddingTransfers containsObject:obj];
|
||
}];
|
||
|
||
[addIndexes removeIndexes:newAddIndexes];
|
||
|
||
[self.fDisplayedTorrents addObjectsFromArray:[allTorrents objectsAtIndexes:newAddIndexes]];
|
||
[self.fTableView insertItemsAtIndexes:[NSIndexSet indexSetWithIndexesInRange:NSMakeRange(
|
||
self.fDisplayedTorrents.count -
|
||
newAddIndexes.count,
|
||
newAddIndexes.count)]
|
||
inParent:nil
|
||
withAnimation:NSTableViewAnimationSlideLeft];
|
||
}
|
||
|
||
[self.fDisplayedTorrents addObjectsFromArray:[allTorrents objectsAtIndexes:addIndexes]];
|
||
[self.fTableView
|
||
insertItemsAtIndexes:[NSIndexSet indexSetWithIndexesInRange:NSMakeRange(
|
||
self.fDisplayedTorrents.count - addIndexes.count,
|
||
addIndexes.count)]
|
||
inParent:nil
|
||
withAnimation:NSTableViewAnimationSlideDown];
|
||
}
|
||
}
|
||
}
|
||
else if (groupRows && wasGroupRows)
|
||
{
|
||
NSAssert(groupRows && wasGroupRows, @"Should have had group rows and should remain with group rows");
|
||
|
||
#warning don't always do?
|
||
beganUpdates = YES;
|
||
[self.fTableView beginUpdates];
|
||
|
||
NSMutableIndexSet* unusedAllTorrentsIndexes = [NSMutableIndexSet indexSetWithIndexesInRange:NSMakeRange(0, allTorrents.count)];
|
||
|
||
NSMutableDictionary* groupsByIndex = [NSMutableDictionary dictionaryWithCapacity:self.fDisplayedTorrents.count];
|
||
for (TorrentGroup* group in self.fDisplayedTorrents)
|
||
{
|
||
groupsByIndex[@(group.groupIndex)] = group;
|
||
}
|
||
|
||
NSUInteger const originalGroupCount = self.fDisplayedTorrents.count;
|
||
for (NSUInteger index = 0; index < originalGroupCount; ++index)
|
||
{
|
||
TorrentGroup* group = self.fDisplayedTorrents[index];
|
||
|
||
NSMutableIndexSet* removeIndexes = [NSMutableIndexSet indexSet];
|
||
|
||
//needs to be a signed integer
|
||
for (NSUInteger indexInGroup = 0; indexInGroup < group.torrents.count; ++indexInGroup)
|
||
{
|
||
Torrent* torrent = group.torrents[indexInGroup];
|
||
NSUInteger const allIndex = [allTorrents indexOfObjectAtIndexes:unusedAllTorrentsIndexes options:NSEnumerationConcurrent
|
||
passingTest:^BOOL(Torrent* obj, NSUInteger /*idx*/, BOOL* /*stop*/) {
|
||
return obj == torrent;
|
||
}];
|
||
if (allIndex == NSNotFound)
|
||
{
|
||
[removeIndexes addIndex:indexInGroup];
|
||
}
|
||
else
|
||
{
|
||
BOOL markTorrentAsUsed = YES;
|
||
|
||
NSInteger const groupValue = torrent.groupValue;
|
||
if (groupValue != group.groupIndex)
|
||
{
|
||
TorrentGroup* newGroup = groupsByIndex[@(groupValue)];
|
||
if (!newGroup)
|
||
{
|
||
newGroup = [[TorrentGroup alloc] initWithGroup:groupValue];
|
||
groupsByIndex[@(groupValue)] = newGroup;
|
||
[self.fDisplayedTorrents addObject:newGroup];
|
||
|
||
[self.fTableView insertItemsAtIndexes:[NSIndexSet indexSetWithIndex:self.fDisplayedTorrents.count - 1]
|
||
inParent:nil
|
||
withAnimation:NSTableViewAnimationEffectFade];
|
||
[self.fTableView isGroupCollapsed:groupValue] ? [self.fTableView collapseItem:newGroup] :
|
||
[self.fTableView expandItem:newGroup];
|
||
}
|
||
else //if we haven't processed the other group yet, we have to make sure we don't flag it for removal the next time
|
||
{
|
||
//ugggh, but shouldn't happen too often
|
||
if ([self.fDisplayedTorrents indexOfObject:newGroup
|
||
inRange:NSMakeRange(index + 1, originalGroupCount - (index + 1))] != NSNotFound)
|
||
{
|
||
markTorrentAsUsed = NO;
|
||
}
|
||
}
|
||
|
||
[group.torrents removeObjectAtIndex:indexInGroup];
|
||
[newGroup.torrents addObject:torrent];
|
||
|
||
[self.fTableView moveItemAtIndex:indexInGroup inParent:group toIndex:newGroup.torrents.count - 1
|
||
inParent:newGroup];
|
||
|
||
--indexInGroup;
|
||
}
|
||
|
||
if (markTorrentAsUsed)
|
||
{
|
||
[unusedAllTorrentsIndexes removeIndex:allIndex];
|
||
}
|
||
}
|
||
}
|
||
|
||
if (removeIndexes.count > 0)
|
||
{
|
||
[group.torrents removeObjectsAtIndexes:removeIndexes];
|
||
[self.fTableView removeItemsAtIndexes:removeIndexes inParent:group withAnimation:NSTableViewAnimationEffectFade];
|
||
}
|
||
}
|
||
|
||
//add remaining new torrents
|
||
for (Torrent* torrent in [allTorrents objectsAtIndexes:unusedAllTorrentsIndexes])
|
||
{
|
||
NSInteger const groupValue = torrent.groupValue;
|
||
TorrentGroup* group = groupsByIndex[@(groupValue)];
|
||
if (!group)
|
||
{
|
||
group = [[TorrentGroup alloc] initWithGroup:groupValue];
|
||
groupsByIndex[@(groupValue)] = group;
|
||
[self.fDisplayedTorrents addObject:group];
|
||
|
||
[self.fTableView insertItemsAtIndexes:[NSIndexSet indexSetWithIndex:self.fDisplayedTorrents.count - 1] inParent:nil
|
||
withAnimation:NSTableViewAnimationEffectFade];
|
||
[self.fTableView isGroupCollapsed:groupValue] ? [self.fTableView collapseItem:group] : [self.fTableView expandItem:group];
|
||
}
|
||
|
||
[group.torrents addObject:torrent];
|
||
|
||
BOOL const newTorrent = [self.fAddingTransfers containsObject:torrent];
|
||
[self.fTableView insertItemsAtIndexes:[NSIndexSet indexSetWithIndex:group.torrents.count - 1] inParent:group
|
||
withAnimation:newTorrent ? NSTableViewAnimationSlideLeft : NSTableViewAnimationSlideDown];
|
||
}
|
||
|
||
//remove empty groups
|
||
NSIndexSet* removeGroupIndexes = [self.fDisplayedTorrents
|
||
indexesOfObjectsAtIndexes:[NSIndexSet indexSetWithIndexesInRange:NSMakeRange(0, originalGroupCount)]
|
||
options:NSEnumerationConcurrent passingTest:^BOOL(id obj, NSUInteger /*idx*/, BOOL* /*stop*/) {
|
||
return ((TorrentGroup*)obj).torrents.count == 0;
|
||
}];
|
||
|
||
if (removeGroupIndexes.count > 0)
|
||
{
|
||
[self.fDisplayedTorrents removeObjectsAtIndexes:removeGroupIndexes];
|
||
[self.fTableView removeItemsAtIndexes:removeGroupIndexes inParent:nil withAnimation:NSTableViewAnimationEffectFade];
|
||
}
|
||
|
||
//now that all groups are there, sort them - don't insert on the fly in case groups were reordered in prefs
|
||
NSSortDescriptor* groupDescriptor = [NSSortDescriptor sortDescriptorWithKey:@"groupOrderValue" ascending:YES];
|
||
[self rearrangeTorrentTableArray:self.fDisplayedTorrents forParent:nil withSortDescriptors:@[ groupDescriptor ]
|
||
beganTableUpdate:&beganUpdates];
|
||
}
|
||
else
|
||
{
|
||
NSAssert(groupRows != wasGroupRows, @"Trying toggling group-torrent reordering when we weren't expecting to.");
|
||
|
||
//set all groups as expanded
|
||
[self.fTableView removeAllCollapsedGroups];
|
||
|
||
// we need to remember selected values
|
||
NSArray<Torrent*>* selectedTorrents = self.fTableView.selectedTorrents;
|
||
|
||
beganUpdates = YES;
|
||
[self.fTableView beginUpdates];
|
||
|
||
[self.fTableView removeItemsAtIndexes:[NSIndexSet indexSetWithIndexesInRange:NSMakeRange(0, self.fDisplayedTorrents.count)]
|
||
inParent:nil
|
||
withAnimation:NSTableViewAnimationSlideDown];
|
||
|
||
if (groupRows)
|
||
{
|
||
//a map for quickly finding groups
|
||
NSMutableDictionary* groupsByIndex = [NSMutableDictionary dictionaryWithCapacity:GroupsController.groups.numberOfGroups];
|
||
for (Torrent* torrent in allTorrents)
|
||
{
|
||
NSInteger const groupValue = torrent.groupValue;
|
||
TorrentGroup* group = groupsByIndex[@(groupValue)];
|
||
if (!group)
|
||
{
|
||
group = [[TorrentGroup alloc] initWithGroup:groupValue];
|
||
groupsByIndex[@(groupValue)] = group;
|
||
}
|
||
|
||
[group.torrents addObject:torrent];
|
||
}
|
||
|
||
[self.fDisplayedTorrents setArray:groupsByIndex.allValues];
|
||
|
||
//we need the groups to be sorted, and we can do it without moving items in the table, too!
|
||
NSSortDescriptor* groupDescriptor = [NSSortDescriptor sortDescriptorWithKey:@"groupOrderValue" ascending:YES];
|
||
[self.fDisplayedTorrents sortUsingDescriptors:@[ groupDescriptor ]];
|
||
}
|
||
else
|
||
[self.fDisplayedTorrents setArray:allTorrents];
|
||
|
||
[self.fTableView insertItemsAtIndexes:[NSIndexSet indexSetWithIndexesInRange:NSMakeRange(0, self.fDisplayedTorrents.count)]
|
||
inParent:nil
|
||
withAnimation:NSTableViewAnimationEffectFade];
|
||
|
||
if (groupRows)
|
||
{
|
||
//actually expand group rows
|
||
for (TorrentGroup* group in self.fDisplayedTorrents)
|
||
[self.fTableView expandItem:group];
|
||
}
|
||
|
||
self.fTableView.selectedTorrents = selectedTorrents;
|
||
}
|
||
|
||
//sort the torrents (won't sort the groups, though)
|
||
[self sortTorrentsCallUpdates:!beganUpdates includeQueueOrder:YES];
|
||
|
||
if (beganUpdates)
|
||
{
|
||
[self.fTableView endUpdates];
|
||
}
|
||
[NSAnimationContext endGrouping];
|
||
|
||
//reloaddata, otherwise the tableview has a bunch of empty cells
|
||
[self.fTableView reloadData];
|
||
|
||
[self resetInfo]; //if group is already selected, but the torrents in it change
|
||
|
||
[self setBottomCountText:groupRows || filterStatus || filterGroup || searchStrings];
|
||
|
||
[self setWindowSizeToFit];
|
||
|
||
if (self.fAddingTransfers)
|
||
{
|
||
self.fAddingTransfers = nil;
|
||
}
|
||
}
|
||
|
||
- (void)switchFilter:(id)sender
|
||
{
|
||
[self.fFilterBar switchFilter:sender == self.fNextFilterItem];
|
||
}
|
||
|
||
- (IBAction)showGlobalPopover:(id)sender
|
||
{
|
||
if (self.fGlobalPopoverShown)
|
||
{
|
||
return;
|
||
}
|
||
|
||
NSPopover* popover = [[NSPopover alloc] init];
|
||
popover.behavior = NSPopoverBehaviorTransient;
|
||
GlobalOptionsPopoverViewController* viewController = [[GlobalOptionsPopoverViewController alloc] initWithHandle:self.fLib];
|
||
popover.contentViewController = viewController;
|
||
popover.delegate = self;
|
||
|
||
NSView* senderView = sender;
|
||
CGFloat width = NSWidth(senderView.frame);
|
||
|
||
if (NSMinX(self.fWindow.frame) < width || NSMaxX(self.fWindow.screen.visibleFrame) - NSMinX(self.fWindow.frame) < width * 2)
|
||
{
|
||
// Ugly hack to hide NSPopover arrow.
|
||
self.fPositioningView = [[NSView alloc] initWithFrame:senderView.bounds];
|
||
self.fPositioningView.identifier = @"positioningView";
|
||
[senderView addSubview:self.fPositioningView];
|
||
[popover showRelativeToRect:self.fPositioningView.bounds ofView:self.fPositioningView preferredEdge:NSMaxYEdge];
|
||
self.fPositioningView.bounds = NSOffsetRect(self.fPositioningView.bounds, 0, NSHeight(self.fPositioningView.bounds));
|
||
}
|
||
else
|
||
{
|
||
[popover showRelativeToRect:senderView.bounds ofView:senderView preferredEdge:NSMaxYEdge];
|
||
}
|
||
}
|
||
|
||
//don't show multiple popovers when clicking the gear button repeatedly
|
||
- (void)popoverWillShow:(NSNotification*)notification
|
||
{
|
||
self.fGlobalPopoverShown = YES;
|
||
}
|
||
|
||
- (void)popoverDidClose:(NSNotification*)notification
|
||
{
|
||
[self.fPositioningView removeFromSuperview];
|
||
self.fGlobalPopoverShown = NO;
|
||
}
|
||
|
||
- (void)menuNeedsUpdate:(NSMenu*)menu
|
||
{
|
||
if (menu == self.fGroupsSetMenu || menu == self.fGroupsSetContextMenu)
|
||
{
|
||
[menu removeAllItems];
|
||
|
||
NSMenu* groupMenu = [GroupsController.groups groupMenuWithTarget:self action:@selector(setGroup:) isSmall:NO];
|
||
|
||
NSInteger const groupMenuCount = groupMenu.numberOfItems;
|
||
for (NSInteger i = 0; i < groupMenuCount; i++)
|
||
{
|
||
NSMenuItem* item = [groupMenu itemAtIndex:0];
|
||
[groupMenu removeItemAtIndex:0];
|
||
[menu addItem:item];
|
||
}
|
||
}
|
||
else if (menu == self.fShareMenu || menu == self.fShareContextMenu)
|
||
{
|
||
[menu removeAllItems];
|
||
|
||
for (NSMenuItem* item in ShareTorrentFileHelper.sharedHelper.menuItems)
|
||
{
|
||
[menu addItem:item];
|
||
}
|
||
}
|
||
}
|
||
|
||
- (void)setGroup:(id)sender
|
||
{
|
||
for (Torrent* torrent in self.fTableView.selectedTorrents)
|
||
{
|
||
[self.fTableView removeCollapsedGroup:torrent.groupValue]; //remove old collapsed group
|
||
|
||
[torrent setGroupValue:((NSMenuItem*)sender).tag determinationType:TorrentDeterminationUserSpecified];
|
||
}
|
||
|
||
[self applyFilter];
|
||
[self updateUI];
|
||
[self updateTorrentHistory];
|
||
}
|
||
|
||
- (void)toggleSpeedLimit:(id)sender
|
||
{
|
||
[self.fDefaults setBool:![self.fDefaults boolForKey:@"SpeedLimit"] forKey:@"SpeedLimit"];
|
||
[self speedLimitChanged:sender];
|
||
}
|
||
|
||
- (void)speedLimitChanged:(id)sender
|
||
{
|
||
tr_sessionUseAltSpeed(self.fLib, [self.fDefaults boolForKey:@"SpeedLimit"]);
|
||
[self.fStatusBar updateSpeedFieldsToolTips];
|
||
}
|
||
|
||
- (void)altSpeedToggledCallbackIsLimited:(NSDictionary*)dict
|
||
{
|
||
BOOL const isLimited = [dict[@"Active"] boolValue];
|
||
|
||
[self.fDefaults setBool:isLimited forKey:@"SpeedLimit"];
|
||
[self.fStatusBar updateSpeedFieldsToolTips];
|
||
|
||
if (![dict[@"ByUser"] boolValue])
|
||
{
|
||
NSString* title = isLimited ? NSLocalizedString(@"Speed Limit Auto Enabled", "notification title") :
|
||
NSLocalizedString(@"Speed Limit Auto Disabled", "notification title");
|
||
NSString* body = NSLocalizedString(@"Bandwidth settings changed", "notification description");
|
||
|
||
NSString* identifier = @"Bandwidth settings changed";
|
||
UNMutableNotificationContent* content = [UNMutableNotificationContent new];
|
||
content.title = title;
|
||
content.body = body;
|
||
|
||
UNNotificationRequest* request = [UNNotificationRequest requestWithIdentifier:identifier content:content trigger:nil];
|
||
[UNUserNotificationCenter.currentNotificationCenter addNotificationRequest:request withCompletionHandler:nil];
|
||
}
|
||
}
|
||
|
||
- (void)sound:(NSSound*)sound didFinishPlaying:(BOOL)finishedPlaying
|
||
{
|
||
self.fSoundPlaying = NO;
|
||
}
|
||
|
||
- (void)VDKQueue:(VDKQueue*)queue receivedNotification:(NSString*)notification forPath:(NSString*)fpath
|
||
{
|
||
//don't assume that just because we're watching for write notification, we'll only receive write notifications
|
||
|
||
if (![self.fDefaults boolForKey:@"AutoImport"] || ![self.fDefaults stringForKey:@"AutoImportDirectory"])
|
||
{
|
||
return;
|
||
}
|
||
|
||
if (self.fAutoImportTimer.valid)
|
||
{
|
||
[self.fAutoImportTimer invalidate];
|
||
}
|
||
|
||
//check again in 10 seconds in case torrent file wasn't complete
|
||
self.fAutoImportTimer = [NSTimer scheduledTimerWithTimeInterval:10.0 target:self
|
||
selector:@selector(checkAutoImportDirectory)
|
||
userInfo:nil
|
||
repeats:NO];
|
||
|
||
[self checkAutoImportDirectory];
|
||
}
|
||
|
||
- (void)changeAutoImport
|
||
{
|
||
if (self.fAutoImportTimer.valid)
|
||
{
|
||
[self.fAutoImportTimer invalidate];
|
||
}
|
||
self.fAutoImportTimer = nil;
|
||
|
||
self.fAutoImportedNames = nil;
|
||
|
||
[self checkAutoImportDirectory];
|
||
}
|
||
|
||
- (void)checkAutoImportDirectory
|
||
{
|
||
NSString* path;
|
||
if (![self.fDefaults boolForKey:@"AutoImport"] || !(path = [self.fDefaults stringForKey:@"AutoImportDirectory"]))
|
||
{
|
||
return;
|
||
}
|
||
|
||
path = path.stringByExpandingTildeInPath;
|
||
|
||
NSArray<NSString*>* importedNames;
|
||
if (!(importedNames = [NSFileManager.defaultManager contentsOfDirectoryAtPath:path error:NULL]))
|
||
{
|
||
return;
|
||
}
|
||
|
||
//only check files that have not been checked yet
|
||
NSMutableArray* newNames = [importedNames mutableCopy];
|
||
|
||
if (self.fAutoImportedNames)
|
||
{
|
||
[newNames removeObjectsInArray:self.fAutoImportedNames];
|
||
}
|
||
else
|
||
{
|
||
self.fAutoImportedNames = [[NSMutableArray alloc] init];
|
||
}
|
||
[self.fAutoImportedNames setArray:importedNames];
|
||
|
||
for (NSString* file in newNames)
|
||
{
|
||
if ([file hasPrefix:@"."])
|
||
{
|
||
continue;
|
||
}
|
||
|
||
NSString* fullFile = [path stringByAppendingPathComponent:file];
|
||
|
||
if (!([[NSWorkspace.sharedWorkspace typeOfFile:fullFile error:NULL] isEqualToString:@"org.bittorrent.torrent"] ||
|
||
[fullFile.pathExtension caseInsensitiveCompare:@"torrent"] == NSOrderedSame))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
NSDictionary<NSFileAttributeKey, id>* fileAttributes = [NSFileManager.defaultManager attributesOfItemAtPath:fullFile
|
||
error:nil];
|
||
if (fileAttributes.fileSize == 0)
|
||
{
|
||
// Workaround for Firefox downloads happening in two steps: first time being an empty file
|
||
[self.fAutoImportedNames removeObject:file];
|
||
continue;
|
||
}
|
||
|
||
auto metainfo = tr_torrent_metainfo{};
|
||
if (!metainfo.parse_torrent_file(fullFile.UTF8String))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
[self openFiles:@[ fullFile ] addType:AddTypeAuto forcePath:nil];
|
||
|
||
NSString* notificationTitle = NSLocalizedString(@"Torrent File Auto Added", "notification title");
|
||
|
||
NSString* identifier = [@"Torrent File Auto Added " stringByAppendingString:file];
|
||
UNMutableNotificationContent* content = [UNMutableNotificationContent new];
|
||
content.title = notificationTitle;
|
||
content.body = file;
|
||
|
||
UNNotificationRequest* request = [UNNotificationRequest requestWithIdentifier:identifier content:content trigger:nil];
|
||
[UNUserNotificationCenter.currentNotificationCenter addNotificationRequest:request withCompletionHandler:nil];
|
||
}
|
||
}
|
||
|
||
- (void)beginCreateFile:(NSNotification*)notification
|
||
{
|
||
if (![self.fDefaults boolForKey:@"AutoImport"])
|
||
{
|
||
return;
|
||
}
|
||
|
||
NSString *location = ((NSURL*)notification.object).path, *path = [self.fDefaults stringForKey:@"AutoImportDirectory"];
|
||
|
||
if (location && path && [location.stringByDeletingLastPathComponent.stringByExpandingTildeInPath isEqualToString:path.stringByExpandingTildeInPath])
|
||
{
|
||
[self.fAutoImportedNames addObject:location.lastPathComponent];
|
||
}
|
||
}
|
||
|
||
- (NSInteger)outlineView:(NSOutlineView*)outlineView numberOfChildrenOfItem:(id)item
|
||
{
|
||
if (item)
|
||
{
|
||
return ((TorrentGroup*)item).torrents.count;
|
||
}
|
||
else
|
||
{
|
||
return self.fDisplayedTorrents.count;
|
||
}
|
||
}
|
||
|
||
- (id)outlineView:(NSOutlineView*)outlineView child:(NSInteger)index ofItem:(id)item
|
||
{
|
||
if (item)
|
||
{
|
||
return ((TorrentGroup*)item).torrents[index];
|
||
}
|
||
else
|
||
{
|
||
return self.fDisplayedTorrents[index];
|
||
}
|
||
}
|
||
|
||
- (BOOL)outlineView:(NSOutlineView*)outlineView isItemExpandable:(id)item
|
||
{
|
||
return ![item isKindOfClass:[Torrent class]];
|
||
}
|
||
|
||
- (BOOL)outlineView:(NSOutlineView*)outlineView writeItems:(NSArray*)items toPasteboard:(NSPasteboard*)pasteboard
|
||
{
|
||
//only allow reordering of rows if sorting by order
|
||
if ([self.fDefaults boolForKey:@"SortByGroup"] || [[self.fDefaults stringForKey:@"Sort"] isEqualToString:SortTypeOrder])
|
||
{
|
||
NSMutableIndexSet* indexSet = [NSMutableIndexSet indexSet];
|
||
for (id torrent in items)
|
||
{
|
||
if (![torrent isKindOfClass:[Torrent class]])
|
||
{
|
||
return NO;
|
||
}
|
||
|
||
[indexSet addIndex:[self.fTableView rowForItem:torrent]];
|
||
}
|
||
|
||
[pasteboard declareTypes:@[ kTorrentTableViewDataType ] owner:self];
|
||
[pasteboard setData:[NSKeyedArchiver archivedDataWithRootObject:indexSet requiringSecureCoding:YES error:nil]
|
||
forType:kTorrentTableViewDataType];
|
||
return YES;
|
||
}
|
||
return NO;
|
||
}
|
||
|
||
- (NSDragOperation)outlineView:(NSOutlineView*)outlineView
|
||
validateDrop:(id<NSDraggingInfo>)info
|
||
proposedItem:(id)item
|
||
proposedChildIndex:(NSInteger)index
|
||
{
|
||
NSPasteboard* pasteboard = info.draggingPasteboard;
|
||
if ([pasteboard.types containsObject:kTorrentTableViewDataType])
|
||
{
|
||
if ([self.fDefaults boolForKey:@"SortByGroup"])
|
||
{
|
||
if (!item)
|
||
{
|
||
return NSDragOperationNone;
|
||
}
|
||
|
||
if ([[self.fDefaults stringForKey:@"Sort"] isEqualToString:SortTypeOrder])
|
||
{
|
||
if ([item isKindOfClass:[Torrent class]])
|
||
{
|
||
TorrentGroup* group = [self.fTableView parentForItem:item];
|
||
index = [group.torrents indexOfObject:item] + 1;
|
||
item = group;
|
||
}
|
||
}
|
||
else
|
||
{
|
||
if ([item isKindOfClass:[Torrent class]])
|
||
{
|
||
item = [self.fTableView parentForItem:item];
|
||
}
|
||
index = NSOutlineViewDropOnItemIndex;
|
||
}
|
||
}
|
||
else
|
||
{
|
||
if (index == NSOutlineViewDropOnItemIndex)
|
||
{
|
||
return NSDragOperationNone;
|
||
}
|
||
|
||
if (item)
|
||
{
|
||
index = [self.fTableView rowForItem:item] + 1;
|
||
item = nil;
|
||
}
|
||
}
|
||
|
||
[self.fTableView setDropItem:item dropChildIndex:index];
|
||
return NSDragOperationGeneric;
|
||
}
|
||
|
||
return NSDragOperationNone;
|
||
}
|
||
|
||
- (BOOL)outlineView:(NSOutlineView*)outlineView acceptDrop:(id<NSDraggingInfo>)info item:(id)item childIndex:(NSInteger)newRow
|
||
{
|
||
NSPasteboard* pasteboard = info.draggingPasteboard;
|
||
if ([pasteboard.types containsObject:kTorrentTableViewDataType])
|
||
{
|
||
NSIndexSet* indexes = [NSKeyedUnarchiver unarchivedObjectOfClass:NSIndexSet.class fromData:[pasteboard dataForType:kTorrentTableViewDataType]
|
||
error:nil];
|
||
|
||
//get the torrents to move
|
||
NSMutableArray* movingTorrents = [NSMutableArray arrayWithCapacity:indexes.count];
|
||
for (NSUInteger i = indexes.firstIndex; i != NSNotFound; i = [indexes indexGreaterThanIndex:i])
|
||
{
|
||
Torrent* torrent = [self.fTableView itemAtRow:i];
|
||
[movingTorrents addObject:torrent];
|
||
}
|
||
|
||
//change groups
|
||
if (item)
|
||
{
|
||
TorrentGroup* group = (TorrentGroup*)item;
|
||
NSInteger const groupIndex = group.groupIndex;
|
||
|
||
for (Torrent* torrent in movingTorrents)
|
||
{
|
||
[torrent setGroupValue:groupIndex determinationType:TorrentDeterminationUserSpecified];
|
||
}
|
||
}
|
||
|
||
//reorder queue order
|
||
if (newRow != NSOutlineViewDropOnItemIndex)
|
||
{
|
||
TorrentGroup* group = (TorrentGroup*)item;
|
||
//find torrent to place under
|
||
NSArray* groupTorrents = group ? group.torrents : self.fDisplayedTorrents;
|
||
Torrent* topTorrent = nil;
|
||
for (NSInteger i = newRow - 1; i >= 0; i--)
|
||
{
|
||
Torrent* tempTorrent = groupTorrents[i];
|
||
if (![movingTorrents containsObject:tempTorrent])
|
||
{
|
||
topTorrent = tempTorrent;
|
||
break;
|
||
}
|
||
}
|
||
|
||
//remove objects to reinsert
|
||
[self.fTorrents removeObjectsInArray:movingTorrents];
|
||
|
||
//insert objects at new location
|
||
NSUInteger const insertIndex = topTorrent ? [self.fTorrents indexOfObject:topTorrent] + 1 : 0;
|
||
NSIndexSet* insertIndexes = [NSIndexSet indexSetWithIndexesInRange:NSMakeRange(insertIndex, movingTorrents.count)];
|
||
[self.fTorrents insertObjects:movingTorrents atIndexes:insertIndexes];
|
||
|
||
//we need to make sure the queue order is updated in the Torrent object before we sort - safest to just reset all queue positions
|
||
NSUInteger i = 0;
|
||
for (Torrent* torrent in self.fTorrents)
|
||
{
|
||
torrent.queuePosition = i++;
|
||
[torrent update];
|
||
}
|
||
|
||
//do the drag animation here so that the dragged torrents are the ones that are animated as moving, and not the torrents around them
|
||
[self.fTableView beginUpdates];
|
||
|
||
NSUInteger insertDisplayIndex = topTorrent ? [groupTorrents indexOfObject:topTorrent] + 1 : 0;
|
||
|
||
for (Torrent* torrent in movingTorrents)
|
||
{
|
||
TorrentGroup* oldParent = item ? [self.fTableView parentForItem:torrent] : nil;
|
||
NSMutableArray* oldTorrents = oldParent ? oldParent.torrents : self.fDisplayedTorrents;
|
||
NSUInteger const oldIndex = [oldTorrents indexOfObject:torrent];
|
||
|
||
if (item == oldParent)
|
||
{
|
||
if (oldIndex < insertDisplayIndex)
|
||
{
|
||
--insertDisplayIndex;
|
||
}
|
||
[oldTorrents moveObjectAtIndex:oldIndex toIndex:insertDisplayIndex];
|
||
}
|
||
else
|
||
{
|
||
NSAssert(item && oldParent, @"Expected to be dragging between group rows");
|
||
|
||
NSMutableArray* newTorrents = ((TorrentGroup*)item).torrents;
|
||
[newTorrents insertObject:torrent atIndex:insertDisplayIndex];
|
||
[oldTorrents removeObjectAtIndex:oldIndex];
|
||
}
|
||
|
||
[self.fTableView moveItemAtIndex:oldIndex inParent:oldParent toIndex:insertDisplayIndex inParent:item];
|
||
|
||
++insertDisplayIndex;
|
||
}
|
||
|
||
[self.fTableView endUpdates];
|
||
}
|
||
|
||
[self applyFilter];
|
||
}
|
||
|
||
return YES;
|
||
}
|
||
|
||
- (void)torrentTableViewSelectionDidChange:(NSNotification*)notification
|
||
{
|
||
[self resetInfo];
|
||
[self.fWindow.toolbar validateVisibleItems];
|
||
}
|
||
|
||
- (NSDragOperation)draggingEntered:(id<NSDraggingInfo>)info
|
||
{
|
||
NSPasteboard* pasteboard = info.draggingPasteboard;
|
||
if ([pasteboard.types containsObject:NSPasteboardTypeFileURL])
|
||
{
|
||
//check if any torrent files can be added
|
||
BOOL torrent = NO;
|
||
NSArray<NSURL*>* files = [pasteboard readObjectsForClasses:@[ NSURL.class ]
|
||
options:@{ NSPasteboardURLReadingFileURLsOnlyKey : @YES }];
|
||
for (NSURL* fileToParse in files)
|
||
{
|
||
if ([[NSWorkspace.sharedWorkspace typeOfFile:fileToParse.path error:NULL] isEqualToString:@"org.bittorrent.torrent"] ||
|
||
[fileToParse.pathExtension caseInsensitiveCompare:@"torrent"] == NSOrderedSame)
|
||
{
|
||
torrent = YES;
|
||
auto metainfo = tr_torrent_metainfo{};
|
||
if (metainfo.parse_torrent_file(fileToParse.path.UTF8String))
|
||
{
|
||
if (!self.fOverlayWindow)
|
||
{
|
||
self.fOverlayWindow = [[DragOverlayWindow alloc] initForWindow:self.fWindow];
|
||
}
|
||
NSMutableArray<NSString*>* filesToOpen = [NSMutableArray arrayWithCapacity:files.count];
|
||
for (NSURL* fileToOpen in files)
|
||
{
|
||
[filesToOpen addObject:fileToOpen.path];
|
||
}
|
||
[self.fOverlayWindow setTorrents:filesToOpen];
|
||
|
||
return NSDragOperationCopy;
|
||
}
|
||
}
|
||
}
|
||
|
||
//create a torrent file if a single file
|
||
if (!torrent && files.count == 1)
|
||
{
|
||
if (!self.fOverlayWindow)
|
||
{
|
||
self.fOverlayWindow = [[DragOverlayWindow alloc] initForWindow:self.fWindow];
|
||
}
|
||
[self.fOverlayWindow setFile:[files[0] lastPathComponent]];
|
||
|
||
return NSDragOperationCopy;
|
||
}
|
||
}
|
||
else if ([pasteboard.types containsObject:NSPasteboardTypeURL])
|
||
{
|
||
if (!self.fOverlayWindow)
|
||
{
|
||
self.fOverlayWindow = [[DragOverlayWindow alloc] initForWindow:self.fWindow];
|
||
}
|
||
[self.fOverlayWindow setURL:[NSURL URLFromPasteboard:pasteboard].relativeString];
|
||
|
||
return NSDragOperationCopy;
|
||
}
|
||
|
||
return NSDragOperationNone;
|
||
}
|
||
|
||
- (void)draggingExited:(id<NSDraggingInfo>)info
|
||
{
|
||
if (self.fOverlayWindow)
|
||
{
|
||
[self.fOverlayWindow fadeOut];
|
||
}
|
||
}
|
||
|
||
- (BOOL)performDragOperation:(id<NSDraggingInfo>)info
|
||
{
|
||
if (self.fOverlayWindow)
|
||
{
|
||
[self.fOverlayWindow fadeOut];
|
||
}
|
||
|
||
NSPasteboard* pasteboard = info.draggingPasteboard;
|
||
if ([pasteboard.types containsObject:NSPasteboardTypeFileURL])
|
||
{
|
||
BOOL torrent = NO, accept = YES;
|
||
|
||
//create an array of files that can be opened
|
||
NSArray<NSURL*>* files = [pasteboard readObjectsForClasses:@[ NSURL.class ]
|
||
options:@{ NSPasteboardURLReadingFileURLsOnlyKey : @YES }];
|
||
NSMutableArray<NSString*>* filesToOpen = [NSMutableArray arrayWithCapacity:files.count];
|
||
for (NSURL* file in files)
|
||
{
|
||
if ([[NSWorkspace.sharedWorkspace typeOfFile:file.path error:NULL] isEqualToString:@"org.bittorrent.torrent"] ||
|
||
[file.pathExtension caseInsensitiveCompare:@"torrent"] == NSOrderedSame)
|
||
{
|
||
torrent = YES;
|
||
auto metainfo = tr_torrent_metainfo{};
|
||
if (metainfo.parse_torrent_file(file.path.UTF8String))
|
||
{
|
||
[filesToOpen addObject:file.path];
|
||
}
|
||
}
|
||
}
|
||
|
||
if (filesToOpen.count > 0)
|
||
{
|
||
[self application:NSApp openFiles:filesToOpen];
|
||
}
|
||
else
|
||
{
|
||
if (!torrent && files.count == 1)
|
||
{
|
||
[CreatorWindowController createTorrentFile:self.fLib forFile:files[0]];
|
||
}
|
||
else
|
||
{
|
||
accept = NO;
|
||
}
|
||
}
|
||
|
||
return accept;
|
||
}
|
||
else if ([pasteboard.types containsObject:NSPasteboardTypeURL])
|
||
{
|
||
NSURL* url;
|
||
if ((url = [NSURL URLFromPasteboard:pasteboard]))
|
||
{
|
||
[self openURL:url.absoluteString];
|
||
return YES;
|
||
}
|
||
}
|
||
|
||
return NO;
|
||
}
|
||
|
||
- (void)toggleSmallView:(id)sender
|
||
{
|
||
BOOL makeSmall = ![self.fDefaults boolForKey:@"SmallView"];
|
||
[self.fDefaults setBool:makeSmall forKey:@"SmallView"];
|
||
|
||
//self.fTableView.usesAlternatingRowBackgroundColors = !makeSmall;
|
||
|
||
self.fTableView.rowHeight = makeSmall ? kRowHeightSmall : kRowHeightRegular;
|
||
|
||
[self.fTableView beginUpdates];
|
||
[self.fTableView
|
||
noteHeightOfRowsWithIndexesChanged:[NSIndexSet indexSetWithIndexesInRange:NSMakeRange(0, self.fTableView.numberOfRows)]];
|
||
[self.fTableView endUpdates];
|
||
|
||
//reloaddata, otherwise the tableview has a bunch of empty cells
|
||
[self.fTableView reloadData];
|
||
[self updateForAutoSize];
|
||
}
|
||
|
||
- (void)togglePiecesBar:(id)sender
|
||
{
|
||
[self.fDefaults setBool:![self.fDefaults boolForKey:@"PiecesBar"] forKey:@"PiecesBar"];
|
||
[self.fTableView togglePiecesBar];
|
||
}
|
||
|
||
- (void)toggleAvailabilityBar:(id)sender
|
||
{
|
||
[self.fDefaults setBool:![self.fDefaults boolForKey:@"DisplayProgressBarAvailable"] forKey:@"DisplayProgressBarAvailable"];
|
||
[self.fTableView display];
|
||
}
|
||
|
||
- (void)toggleStatusBar:(id)sender
|
||
{
|
||
BOOL const show = self.fStatusBar == nil || self.fStatusBar.isHidden;
|
||
[self.fDefaults setBool:show forKey:@"StatusBar"];
|
||
[self updateMainWindow];
|
||
}
|
||
|
||
- (void)toggleFilterBar:(id)sender
|
||
{
|
||
BOOL const show = self.fFilterBar == nil || self.fFilterBar.isHidden;
|
||
|
||
//disable filtering when hiding (have to do before updateMainWindow:)
|
||
if (!show)
|
||
{
|
||
[self.fFilterBar reset];
|
||
}
|
||
|
||
[self.fDefaults setBool:show forKey:@"FilterBar"];
|
||
[self updateMainWindow];
|
||
}
|
||
|
||
- (IBAction)toggleToolbarShown:(id)sender
|
||
{
|
||
[self.fWindow toggleToolbarShown:sender];
|
||
}
|
||
|
||
- (void)focusFilterField
|
||
{
|
||
if (self.fFilterBar == nil || self.fFilterBar.isHidden)
|
||
{
|
||
[self toggleFilterBar:self];
|
||
}
|
||
[self.fFilterBar focusSearchField];
|
||
}
|
||
|
||
- (BOOL)acceptsPreviewPanelControl:(QLPreviewPanel*)panel
|
||
{
|
||
return !self.fQuitting;
|
||
}
|
||
|
||
- (void)beginPreviewPanelControl:(QLPreviewPanel*)panel
|
||
{
|
||
self.fPreviewPanel = panel;
|
||
self.fPreviewPanel.delegate = self;
|
||
self.fPreviewPanel.dataSource = self;
|
||
}
|
||
|
||
- (void)endPreviewPanelControl:(QLPreviewPanel*)panel
|
||
{
|
||
self.fPreviewPanel = nil;
|
||
[self.fWindow.toolbar validateVisibleItems];
|
||
}
|
||
|
||
- (NSArray<Torrent*>*)quickLookableTorrents
|
||
{
|
||
NSArray* selectedTorrents = self.fTableView.selectedTorrents;
|
||
NSMutableArray* qlArray = [NSMutableArray arrayWithCapacity:selectedTorrents.count];
|
||
|
||
for (Torrent* torrent in selectedTorrents)
|
||
{
|
||
if ((torrent.folder || torrent.complete) && torrent.dataLocation)
|
||
{
|
||
[qlArray addObject:torrent];
|
||
}
|
||
}
|
||
|
||
return qlArray;
|
||
}
|
||
|
||
- (NSInteger)numberOfPreviewItemsInPreviewPanel:(QLPreviewPanel*)panel
|
||
{
|
||
if (self.fInfoController.canQuickLook)
|
||
{
|
||
return self.fInfoController.quickLookURLs.count;
|
||
}
|
||
else
|
||
{
|
||
return [self quickLookableTorrents].count;
|
||
}
|
||
}
|
||
|
||
- (id<QLPreviewItem>)previewPanel:(QLPreviewPanel*)panel previewItemAtIndex:(NSInteger)index
|
||
{
|
||
if (self.fInfoController.canQuickLook)
|
||
{
|
||
return self.fInfoController.quickLookURLs[index];
|
||
}
|
||
else
|
||
{
|
||
return [self quickLookableTorrents][index];
|
||
}
|
||
}
|
||
|
||
- (BOOL)previewPanel:(QLPreviewPanel*)panel handleEvent:(NSEvent*)event
|
||
{
|
||
/*if ([event type] == NSKeyDown)
|
||
{
|
||
[super keyDown: event];
|
||
return YES;
|
||
}*/
|
||
|
||
return NO;
|
||
}
|
||
|
||
- (NSRect)previewPanel:(QLPreviewPanel*)panel sourceFrameOnScreenForPreviewItem:(id<QLPreviewItem>)item
|
||
{
|
||
if (self.fInfoController.canQuickLook)
|
||
{
|
||
return [self.fInfoController quickLookSourceFrameForPreviewItem:item];
|
||
}
|
||
else
|
||
{
|
||
if (!self.fWindow.visible)
|
||
{
|
||
return NSZeroRect;
|
||
}
|
||
|
||
NSInteger const row = [self.fTableView rowForItem:item];
|
||
if (row == -1)
|
||
{
|
||
return NSZeroRect;
|
||
}
|
||
|
||
NSRect frame = [self.fTableView iconRectForRow:row];
|
||
|
||
if (!NSIntersectsRect(self.fTableView.visibleRect, frame))
|
||
{
|
||
return NSZeroRect;
|
||
}
|
||
|
||
frame.origin = [self.fTableView convertPoint:frame.origin toView:nil];
|
||
frame = [self.fWindow convertRectToScreen:frame];
|
||
frame.origin.y -= frame.size.height;
|
||
return frame;
|
||
}
|
||
}
|
||
|
||
- (void)showToolbarShare:(id)sender
|
||
{
|
||
NSParameterAssert([sender isKindOfClass:[NSButton class]]);
|
||
NSButton* senderButton = sender;
|
||
|
||
NSSharingServicePicker* picker = [[NSSharingServicePicker alloc] initWithItems:ShareTorrentFileHelper.sharedHelper.shareTorrentURLs];
|
||
picker.delegate = self;
|
||
|
||
[picker showRelativeToRect:senderButton.bounds ofView:senderButton preferredEdge:NSMinYEdge];
|
||
}
|
||
|
||
- (id<NSSharingServiceDelegate>)sharingServicePicker:(NSSharingServicePicker*)sharingServicePicker
|
||
delegateForSharingService:(NSSharingService*)sharingService
|
||
{
|
||
return self;
|
||
}
|
||
|
||
- (NSWindow*)sharingService:(NSSharingService*)sharingService
|
||
sourceWindowForShareItems:(NSArray*)items
|
||
sharingContentScope:(NSSharingContentScope*)sharingContentScope
|
||
{
|
||
return self.fWindow;
|
||
}
|
||
|
||
- (ButtonToolbarItem*)standardToolbarButtonWithIdentifier:(NSString*)ident
|
||
{
|
||
return [self toolbarButtonWithIdentifier:ident forToolbarButtonClass:[ButtonToolbarItem class]];
|
||
}
|
||
|
||
- (__kindof ButtonToolbarItem*)toolbarButtonWithIdentifier:(NSString*)ident forToolbarButtonClass:(Class)klass
|
||
{
|
||
ButtonToolbarItem* item = [[klass alloc] initWithItemIdentifier:ident];
|
||
|
||
NSButton* button = [[NSButton alloc] init];
|
||
button.bezelStyle = NSBezelStyleTexturedRounded;
|
||
button.stringValue = @"";
|
||
|
||
item.view = button;
|
||
|
||
return item;
|
||
}
|
||
|
||
- (NSToolbarItem*)toolbar:(NSToolbar*)toolbar itemForItemIdentifier:(NSString*)ident willBeInsertedIntoToolbar:(BOOL)flag
|
||
{
|
||
if ([ident isEqualToString:ToolbarItemIdentifierCreate])
|
||
{
|
||
ButtonToolbarItem* item = [self standardToolbarButtonWithIdentifier:ident];
|
||
|
||
item.label = NSLocalizedString(@"Create", "Create toolbar item -> label");
|
||
item.paletteLabel = NSLocalizedString(@"Create Torrent File", "Create toolbar item -> palette label");
|
||
item.toolTip = NSLocalizedString(@"Create torrent file", "Create toolbar item -> tooltip");
|
||
item.image = [NSImage imageWithSystemSymbolName:@"doc.badge.plus" accessibilityDescription:nil];
|
||
item.target = self;
|
||
item.action = @selector(createFile:);
|
||
item.autovalidates = NO;
|
||
|
||
return item;
|
||
}
|
||
else if ([ident isEqualToString:ToolbarItemIdentifierOpenFile])
|
||
{
|
||
ButtonToolbarItem* item = [self standardToolbarButtonWithIdentifier:ident];
|
||
|
||
item.label = NSLocalizedString(@"Open", "Open toolbar item -> label");
|
||
item.paletteLabel = NSLocalizedString(@"Open Torrent Files", "Open toolbar item -> palette label");
|
||
item.toolTip = NSLocalizedString(@"Open torrent files", "Open toolbar item -> tooltip");
|
||
item.image = [NSImage imageWithSystemSymbolName:@"folder" accessibilityDescription:nil];
|
||
item.target = self;
|
||
item.action = @selector(openShowSheet:);
|
||
item.autovalidates = NO;
|
||
|
||
return item;
|
||
}
|
||
else if ([ident isEqualToString:ToolbarItemIdentifierOpenWeb])
|
||
{
|
||
ButtonToolbarItem* item = [self standardToolbarButtonWithIdentifier:ident];
|
||
|
||
item.label = NSLocalizedString(@"Open Address", "Open address toolbar item -> label");
|
||
item.paletteLabel = NSLocalizedString(@"Open Torrent Address", "Open address toolbar item -> palette label");
|
||
item.toolTip = NSLocalizedString(@"Open torrent web address", "Open address toolbar item -> tooltip");
|
||
item.image = [NSImage imageWithSystemSymbolName:@"globe" accessibilityDescription:nil];
|
||
item.target = self;
|
||
item.action = @selector(openURLShowSheet:);
|
||
item.autovalidates = NO;
|
||
|
||
return item;
|
||
}
|
||
else if ([ident isEqualToString:ToolbarItemIdentifierRemove])
|
||
{
|
||
ButtonToolbarItem* item = [self standardToolbarButtonWithIdentifier:ident];
|
||
|
||
item.label = NSLocalizedString(@"Remove", "Remove toolbar item -> label");
|
||
item.paletteLabel = NSLocalizedString(@"Remove Selected", "Remove toolbar item -> palette label");
|
||
item.toolTip = NSLocalizedString(@"Remove selected transfers", "Remove toolbar item -> tooltip");
|
||
item.image = [NSImage imageWithSystemSymbolName:@"nosign" accessibilityDescription:nil];
|
||
item.target = self;
|
||
item.action = @selector(removeNoDelete:);
|
||
item.visibilityPriority = NSToolbarItemVisibilityPriorityHigh;
|
||
|
||
return item;
|
||
}
|
||
else if ([ident isEqualToString:ToolbarItemIdentifierInfo])
|
||
{
|
||
ButtonToolbarItem* item = [self standardToolbarButtonWithIdentifier:ident];
|
||
((NSButtonCell*)((NSButton*)item.view).cell).showsStateBy = NSContentsCellMask; //blue when enabled
|
||
|
||
item.label = NSLocalizedString(@"Inspector", "Inspector toolbar item -> label");
|
||
item.paletteLabel = NSLocalizedString(@"Toggle Inspector", "Inspector toolbar item -> palette label");
|
||
item.toolTip = NSLocalizedString(@"Toggle the torrent inspector", "Inspector toolbar item -> tooltip");
|
||
item.image = [NSImage imageWithSystemSymbolName:@"info.circle" accessibilityDescription:nil];
|
||
item.target = self;
|
||
item.action = @selector(showInfo:);
|
||
|
||
return item;
|
||
}
|
||
else if ([ident isEqualToString:ToolbarItemIdentifierPauseResumeAll])
|
||
{
|
||
GroupToolbarItem* groupItem = [[GroupToolbarItem alloc] initWithItemIdentifier:ident];
|
||
|
||
NSToolbarItem* itemPause = [self standardToolbarButtonWithIdentifier:ToolbarItemIdentifierPauseAll];
|
||
NSToolbarItem* itemResume = [self standardToolbarButtonWithIdentifier:ToolbarItemIdentifierResumeAll];
|
||
|
||
NSSegmentedControl* segmentedControl = [[NSSegmentedControl alloc] initWithFrame:NSZeroRect];
|
||
segmentedControl.segmentStyle = NSSegmentStyleTexturedRounded;
|
||
segmentedControl.trackingMode = NSSegmentSwitchTrackingMomentary;
|
||
segmentedControl.segmentCount = 2;
|
||
|
||
[segmentedControl setTag:ToolbarGroupTagPause forSegment:ToolbarGroupTagPause];
|
||
[segmentedControl setImage:[NSImage imageWithSystemSymbolName:@"pause.circle.fill" accessibilityDescription:nil]
|
||
forSegment:ToolbarGroupTagPause];
|
||
[segmentedControl setToolTip:NSLocalizedString(@"Pause all transfers", "All toolbar item -> tooltip")
|
||
forSegment:ToolbarGroupTagPause];
|
||
|
||
[segmentedControl setTag:ToolbarGroupTagResume forSegment:ToolbarGroupTagResume];
|
||
[segmentedControl setImage:[NSImage imageWithSystemSymbolName:@"arrow.clockwise.circle.fill" accessibilityDescription:nil]
|
||
forSegment:ToolbarGroupTagResume];
|
||
[segmentedControl setToolTip:NSLocalizedString(@"Resume all transfers", "All toolbar item -> tooltip")
|
||
forSegment:ToolbarGroupTagResume];
|
||
if ([toolbar isKindOfClass:Toolbar.class] && ((Toolbar*)toolbar).isRunningCustomizationPalette)
|
||
{
|
||
// On macOS 13.2, the palette autolayout will hang unless the segmentedControl width is longer than the groupItem paletteLabel (matters especially in Russian and French).
|
||
[segmentedControl setWidth:64 forSegment:ToolbarGroupTagPause];
|
||
[segmentedControl setWidth:64 forSegment:ToolbarGroupTagResume];
|
||
}
|
||
|
||
groupItem.label = NSLocalizedString(@"Apply All", "All toolbar item -> label");
|
||
groupItem.paletteLabel = NSLocalizedString(@"Pause / Resume All", "All toolbar item -> palette label");
|
||
groupItem.visibilityPriority = NSToolbarItemVisibilityPriorityHigh;
|
||
groupItem.subitems = @[ itemPause, itemResume ];
|
||
groupItem.view = segmentedControl;
|
||
groupItem.target = self;
|
||
groupItem.action = @selector(allToolbarClicked:);
|
||
|
||
[groupItem createMenu:@[
|
||
NSLocalizedString(@"Pause All", "All toolbar item -> label"),
|
||
NSLocalizedString(@"Resume All", "All toolbar item -> label")
|
||
]];
|
||
|
||
return groupItem;
|
||
}
|
||
else if ([ident isEqualToString:ToolbarItemIdentifierPauseResumeSelected])
|
||
{
|
||
GroupToolbarItem* groupItem = [[GroupToolbarItem alloc] initWithItemIdentifier:ident];
|
||
|
||
NSToolbarItem* itemPause = [self standardToolbarButtonWithIdentifier:ToolbarItemIdentifierPauseSelected];
|
||
NSToolbarItem* itemResume = [self standardToolbarButtonWithIdentifier:ToolbarItemIdentifierResumeSelected];
|
||
|
||
NSSegmentedControl* segmentedControl = [[NSSegmentedControl alloc] initWithFrame:NSZeroRect];
|
||
segmentedControl.segmentStyle = NSSegmentStyleTexturedRounded;
|
||
segmentedControl.trackingMode = NSSegmentSwitchTrackingMomentary;
|
||
segmentedControl.segmentCount = 2;
|
||
|
||
[segmentedControl setTag:ToolbarGroupTagPause forSegment:ToolbarGroupTagPause];
|
||
[segmentedControl setImage:[NSImage imageWithSystemSymbolName:@"pause" accessibilityDescription:nil]
|
||
forSegment:ToolbarGroupTagPause];
|
||
[segmentedControl setToolTip:NSLocalizedString(@"Pause selected transfers", "Selected toolbar item -> tooltip")
|
||
forSegment:ToolbarGroupTagPause];
|
||
|
||
[segmentedControl setTag:ToolbarGroupTagResume forSegment:ToolbarGroupTagResume];
|
||
[segmentedControl setImage:[NSImage imageWithSystemSymbolName:@"arrow.clockwise" accessibilityDescription:nil]
|
||
forSegment:ToolbarGroupTagResume];
|
||
[segmentedControl setToolTip:NSLocalizedString(@"Resume selected transfers", "Selected toolbar item -> tooltip")
|
||
forSegment:ToolbarGroupTagResume];
|
||
if ([toolbar isKindOfClass:Toolbar.class] && ((Toolbar*)toolbar).isRunningCustomizationPalette)
|
||
{
|
||
// On macOS 13.2, the palette autolayout will hang unless the segmentedControl width is longer than the groupItem paletteLabel (matters especially in Russian and French).
|
||
[segmentedControl setWidth:64 forSegment:ToolbarGroupTagPause];
|
||
[segmentedControl setWidth:64 forSegment:ToolbarGroupTagResume];
|
||
}
|
||
|
||
groupItem.label = NSLocalizedString(@"Apply Selected", "Selected toolbar item -> label");
|
||
groupItem.paletteLabel = NSLocalizedString(@"Pause / Resume Selected", "Selected toolbar item -> palette label");
|
||
groupItem.visibilityPriority = NSToolbarItemVisibilityPriorityHigh;
|
||
groupItem.subitems = @[ itemPause, itemResume ];
|
||
groupItem.view = segmentedControl;
|
||
groupItem.target = self;
|
||
groupItem.action = @selector(selectedToolbarClicked:);
|
||
|
||
[groupItem createMenu:@[
|
||
NSLocalizedString(@"Pause Selected", "Selected toolbar item -> label"),
|
||
NSLocalizedString(@"Resume Selected", "Selected toolbar item -> label")
|
||
]];
|
||
|
||
return groupItem;
|
||
}
|
||
else if ([ident isEqualToString:ToolbarItemIdentifierFilter])
|
||
{
|
||
ButtonToolbarItem* item = [self standardToolbarButtonWithIdentifier:ident];
|
||
((NSButtonCell*)((NSButton*)item.view).cell).showsStateBy = NSContentsCellMask; //blue when enabled
|
||
|
||
item.label = NSLocalizedString(@"Filter", "Filter toolbar item -> label");
|
||
item.paletteLabel = NSLocalizedString(@"Toggle Filter", "Filter toolbar item -> palette label");
|
||
item.toolTip = NSLocalizedString(@"Toggle the filter bar", "Filter toolbar item -> tooltip");
|
||
item.image = [NSImage imageWithSystemSymbolName:@"magnifyingglass" accessibilityDescription:nil];
|
||
item.target = self;
|
||
item.action = @selector(toggleFilterBar:);
|
||
|
||
return item;
|
||
}
|
||
else if ([ident isEqualToString:ToolbarItemIdentifierQuickLook])
|
||
{
|
||
ButtonToolbarItem* item = [self standardToolbarButtonWithIdentifier:ident];
|
||
((NSButtonCell*)((NSButton*)item.view).cell).showsStateBy = NSContentsCellMask; //blue when enabled
|
||
|
||
item.label = NSLocalizedString(@"Quick Look", "QuickLook toolbar item -> label");
|
||
item.paletteLabel = NSLocalizedString(@"Quick Look", "QuickLook toolbar item -> palette label");
|
||
item.toolTip = NSLocalizedString(@"Quick Look", "QuickLook toolbar item -> tooltip");
|
||
item.image = [NSImage imageNamed:NSImageNameQuickLookTemplate];
|
||
item.target = self;
|
||
item.action = @selector(toggleQuickLook:);
|
||
item.visibilityPriority = NSToolbarItemVisibilityPriorityLow;
|
||
|
||
return item;
|
||
}
|
||
else if ([ident isEqualToString:ToolbarItemIdentifierShare])
|
||
{
|
||
ShareToolbarItem* item = [self toolbarButtonWithIdentifier:ident forToolbarButtonClass:[ShareToolbarItem class]];
|
||
|
||
item.label = NSLocalizedString(@"Share", "Share toolbar item -> label");
|
||
item.paletteLabel = NSLocalizedString(@"Share", "Share toolbar item -> palette label");
|
||
item.toolTip = NSLocalizedString(@"Share torrent file", "Share toolbar item -> tooltip");
|
||
item.image = [NSImage imageNamed:NSImageNameShareTemplate];
|
||
item.visibilityPriority = NSToolbarItemVisibilityPriorityLow;
|
||
|
||
NSButton* itemButton = (NSButton*)item.view;
|
||
itemButton.target = self;
|
||
itemButton.action = @selector(showToolbarShare:);
|
||
[itemButton sendActionOn:NSEventMaskLeftMouseDown];
|
||
|
||
return item;
|
||
}
|
||
else
|
||
{
|
||
return nil;
|
||
}
|
||
}
|
||
|
||
- (void)allToolbarClicked:(id)sender
|
||
{
|
||
NSInteger tagValue = [sender isKindOfClass:[NSSegmentedControl class]] ? [(NSSegmentedControl*)sender selectedTag] :
|
||
((NSControl*)sender).tag;
|
||
switch (tagValue)
|
||
{
|
||
case ToolbarGroupTagPause:
|
||
[self stopAllTorrents:sender];
|
||
break;
|
||
case ToolbarGroupTagResume:
|
||
[self resumeAllTorrents:sender];
|
||
break;
|
||
}
|
||
}
|
||
|
||
- (void)selectedToolbarClicked:(id)sender
|
||
{
|
||
NSInteger tagValue = [sender isKindOfClass:[NSSegmentedControl class]] ? [(NSSegmentedControl*)sender selectedTag] :
|
||
((NSControl*)sender).tag;
|
||
switch (tagValue)
|
||
{
|
||
case ToolbarGroupTagPause:
|
||
[self stopSelectedTorrents:sender];
|
||
break;
|
||
case ToolbarGroupTagResume:
|
||
[self resumeSelectedTorrents:sender];
|
||
break;
|
||
}
|
||
}
|
||
|
||
- (NSArray*)toolbarAllowedItemIdentifiers:(NSToolbar*)toolbar
|
||
{
|
||
return @[
|
||
ToolbarItemIdentifierCreate,
|
||
ToolbarItemIdentifierOpenFile,
|
||
ToolbarItemIdentifierOpenWeb,
|
||
ToolbarItemIdentifierRemove,
|
||
ToolbarItemIdentifierPauseResumeSelected,
|
||
ToolbarItemIdentifierPauseResumeAll,
|
||
ToolbarItemIdentifierShare,
|
||
ToolbarItemIdentifierQuickLook,
|
||
ToolbarItemIdentifierFilter,
|
||
ToolbarItemIdentifierInfo,
|
||
NSToolbarSpaceItemIdentifier,
|
||
NSToolbarFlexibleSpaceItemIdentifier
|
||
];
|
||
}
|
||
|
||
- (NSArray*)toolbarDefaultItemIdentifiers:(NSToolbar*)toolbar
|
||
{
|
||
return @[
|
||
ToolbarItemIdentifierCreate,
|
||
ToolbarItemIdentifierOpenFile,
|
||
ToolbarItemIdentifierRemove,
|
||
NSToolbarSpaceItemIdentifier,
|
||
ToolbarItemIdentifierPauseResumeAll,
|
||
NSToolbarFlexibleSpaceItemIdentifier,
|
||
ToolbarItemIdentifierShare,
|
||
ToolbarItemIdentifierQuickLook,
|
||
ToolbarItemIdentifierFilter,
|
||
ToolbarItemIdentifierInfo,
|
||
];
|
||
}
|
||
|
||
- (BOOL)validateToolbarItem:(NSToolbarItem*)toolbarItem
|
||
{
|
||
NSString* ident = toolbarItem.itemIdentifier;
|
||
|
||
//enable remove item
|
||
if ([ident isEqualToString:ToolbarItemIdentifierRemove])
|
||
{
|
||
return self.fTableView.numberOfSelectedRows > 0;
|
||
}
|
||
|
||
//enable pause all item
|
||
if ([ident isEqualToString:ToolbarItemIdentifierPauseAll])
|
||
{
|
||
for (Torrent* torrent in self.fTorrents)
|
||
{
|
||
if (torrent.active || torrent.waitingToStart)
|
||
{
|
||
return YES;
|
||
}
|
||
}
|
||
return NO;
|
||
}
|
||
|
||
//enable resume all item
|
||
if ([ident isEqualToString:ToolbarItemIdentifierResumeAll])
|
||
{
|
||
for (Torrent* torrent in self.fTorrents)
|
||
{
|
||
if (!torrent.active && !torrent.waitingToStart && !torrent.finishedSeeding)
|
||
{
|
||
return YES;
|
||
}
|
||
}
|
||
return NO;
|
||
}
|
||
|
||
//enable pause item
|
||
if ([ident isEqualToString:ToolbarItemIdentifierPauseSelected])
|
||
{
|
||
for (Torrent* torrent in self.fTableView.selectedTorrents)
|
||
{
|
||
if (torrent.active || torrent.waitingToStart)
|
||
{
|
||
return YES;
|
||
}
|
||
}
|
||
return NO;
|
||
}
|
||
|
||
//enable resume item
|
||
if ([ident isEqualToString:ToolbarItemIdentifierResumeSelected])
|
||
{
|
||
for (Torrent* torrent in self.fTableView.selectedTorrents)
|
||
{
|
||
if (!torrent.active && !torrent.waitingToStart)
|
||
{
|
||
return YES;
|
||
}
|
||
}
|
||
return NO;
|
||
}
|
||
|
||
//set info item
|
||
if ([ident isEqualToString:ToolbarItemIdentifierInfo])
|
||
{
|
||
((NSButton*)toolbarItem.view).state = self.fInfoController.window.visible;
|
||
return YES;
|
||
}
|
||
|
||
//set filter item
|
||
if ([ident isEqualToString:ToolbarItemIdentifierFilter])
|
||
{
|
||
BOOL shown = !(self.fFilterBar == nil || self.fFilterBar.isHidden);
|
||
((NSButton*)toolbarItem.view).state = shown ? NSControlStateValueOn : NSControlStateValueOff;
|
||
return YES;
|
||
}
|
||
|
||
//set quick look item
|
||
if ([ident isEqualToString:ToolbarItemIdentifierQuickLook])
|
||
{
|
||
((NSButton*)toolbarItem.view).state = self.fPreviewPanel != nil;
|
||
return self.fTableView.numberOfSelectedRows > 0;
|
||
}
|
||
|
||
//enable share item
|
||
if ([ident isEqualToString:ToolbarItemIdentifierShare])
|
||
{
|
||
return self.fTableView.numberOfSelectedRows > 0;
|
||
}
|
||
|
||
return YES;
|
||
}
|
||
|
||
- (BOOL)validateMenuItem:(NSMenuItem*)menuItem
|
||
{
|
||
SEL action = menuItem.action;
|
||
|
||
if (action == @selector(toggleSpeedLimit:))
|
||
{
|
||
menuItem.state = [self.fDefaults boolForKey:@"SpeedLimit"] ? NSControlStateValueOn : NSControlStateValueOff;
|
||
return YES;
|
||
}
|
||
|
||
//only enable some items if it is in a context menu or the window is usable
|
||
BOOL canUseTable = self.fWindow.keyWindow || menuItem.menu.supermenu != NSApp.mainMenu;
|
||
|
||
//enable open items
|
||
if (action == @selector(openShowSheet:) || action == @selector(openURLShowSheet:))
|
||
{
|
||
return self.fWindow.attachedSheet == nil;
|
||
}
|
||
|
||
//enable sort options
|
||
if (action == @selector(setSort:))
|
||
{
|
||
SortType sortType;
|
||
switch (menuItem.tag)
|
||
{
|
||
case SortTagOrder:
|
||
sortType = SortTypeOrder;
|
||
break;
|
||
case SortTagDate:
|
||
sortType = SortTypeDate;
|
||
break;
|
||
case SortTagName:
|
||
sortType = SortTypeName;
|
||
break;
|
||
case SortTagProgress:
|
||
sortType = SortTypeProgress;
|
||
break;
|
||
case SortTagState:
|
||
sortType = SortTypeState;
|
||
break;
|
||
case SortTagTracker:
|
||
sortType = SortTypeTracker;
|
||
break;
|
||
case SortTagActivity:
|
||
sortType = SortTypeActivity;
|
||
break;
|
||
case SortTagSize:
|
||
sortType = SortTypeSize;
|
||
break;
|
||
case SortTagETA:
|
||
sortType = SortTypeETA;
|
||
break;
|
||
default:
|
||
NSAssert1(NO, @"Unknown sort tag received: %ld", [menuItem tag]);
|
||
sortType = SortTypeOrder;
|
||
}
|
||
|
||
menuItem.state = [sortType isEqualToString:[self.fDefaults stringForKey:@"Sort"]] ? NSControlStateValueOn : NSControlStateValueOff;
|
||
return self.fWindow.visible;
|
||
}
|
||
|
||
if (action == @selector(setGroup:))
|
||
{
|
||
BOOL checked = NO;
|
||
|
||
NSInteger index = menuItem.tag;
|
||
for (Torrent* torrent in self.fTableView.selectedTorrents)
|
||
{
|
||
if (index == torrent.groupValue)
|
||
{
|
||
checked = YES;
|
||
break;
|
||
}
|
||
}
|
||
|
||
menuItem.state = checked ? NSControlStateValueOn : NSControlStateValueOff;
|
||
return canUseTable && self.fTableView.numberOfSelectedRows > 0;
|
||
}
|
||
|
||
if (action == @selector(toggleSmallView:))
|
||
{
|
||
menuItem.state = [self.fDefaults boolForKey:@"SmallView"] ? NSControlStateValueOn : NSControlStateValueOff;
|
||
return self.fWindow.visible;
|
||
}
|
||
|
||
if (action == @selector(togglePiecesBar:))
|
||
{
|
||
menuItem.state = [self.fDefaults boolForKey:@"PiecesBar"] ? NSControlStateValueOn : NSControlStateValueOff;
|
||
return self.fWindow.visible;
|
||
}
|
||
|
||
if (action == @selector(toggleAvailabilityBar:))
|
||
{
|
||
menuItem.state = [self.fDefaults boolForKey:@"DisplayProgressBarAvailable"] ? NSControlStateValueOn : NSControlStateValueOff;
|
||
return self.fWindow.visible;
|
||
}
|
||
|
||
//enable show info
|
||
if (action == @selector(showInfo:))
|
||
{
|
||
NSString* title = self.fInfoController.window.visible ? NSLocalizedString(@"Hide Inspector", "View menu -> Inspector") :
|
||
NSLocalizedString(@"Show Inspector", "View menu -> Inspector");
|
||
menuItem.title = title;
|
||
|
||
return YES;
|
||
}
|
||
|
||
//enable prev/next inspector tab
|
||
if (action == @selector(setInfoTab:))
|
||
{
|
||
return self.fInfoController.window.visible;
|
||
}
|
||
|
||
//enable toggle status bar
|
||
if (action == @selector(toggleStatusBar:))
|
||
{
|
||
NSString* title = !self.fStatusBar ? NSLocalizedString(@"Show Status Bar", "View menu -> Status Bar") :
|
||
NSLocalizedString(@"Hide Status Bar", "View menu -> Status Bar");
|
||
menuItem.title = title;
|
||
|
||
return self.fWindow.visible;
|
||
}
|
||
|
||
//enable toggle filter bar
|
||
if (action == @selector(toggleFilterBar:))
|
||
{
|
||
NSString* title = !self.fFilterBar ? NSLocalizedString(@"Show Filter Bar", "View menu -> Filter Bar") :
|
||
NSLocalizedString(@"Hide Filter Bar", "View menu -> Filter Bar");
|
||
menuItem.title = title;
|
||
|
||
return self.fWindow.visible;
|
||
}
|
||
|
||
// enable toggle toolbar
|
||
if (action == @selector(toggleToolbarShown:))
|
||
{
|
||
NSString* title = !self.fWindow.toolbar.isVisible ? NSLocalizedString(@"Show Toolbar", "View menu -> Toolbar") :
|
||
NSLocalizedString(@"Hide Toolbar", "View menu -> Toolbar");
|
||
menuItem.title = title;
|
||
|
||
return self.fWindow.visible;
|
||
}
|
||
|
||
//enable prev/next filter button
|
||
if (action == @selector(switchFilter:))
|
||
{
|
||
return self.fWindow.visible && self.fFilterBar;
|
||
}
|
||
|
||
//enable reveal in finder
|
||
if (action == @selector(revealFile:))
|
||
{
|
||
return canUseTable && self.fTableView.numberOfSelectedRows > 0;
|
||
}
|
||
|
||
//enable renaming file/folder
|
||
if (action == @selector(renameSelected:))
|
||
{
|
||
return canUseTable && self.fTableView.numberOfSelectedRows == 1;
|
||
}
|
||
|
||
//enable remove items
|
||
if (action == @selector(removeNoDelete:) || action == @selector(removeDeleteData:))
|
||
{
|
||
BOOL warning = NO;
|
||
|
||
if (self.fFilterBar.isFocused == YES)
|
||
{
|
||
return NO;
|
||
}
|
||
|
||
for (Torrent* torrent in self.fTableView.selectedTorrents)
|
||
{
|
||
if (torrent.active)
|
||
{
|
||
if ([self.fDefaults boolForKey:@"CheckRemoveDownloading"] ? !torrent.seeding : YES)
|
||
{
|
||
warning = YES;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
//append or remove ellipsis when needed
|
||
NSString *title = menuItem.title, *ellipsis = NSString.ellipsis;
|
||
if (warning && [self.fDefaults boolForKey:@"CheckRemove"])
|
||
{
|
||
if (![title hasSuffix:ellipsis])
|
||
{
|
||
menuItem.title = [title stringByAppendingEllipsis];
|
||
}
|
||
}
|
||
else
|
||
{
|
||
if ([title hasSuffix:ellipsis])
|
||
{
|
||
menuItem.title = [title substringToIndex:[title rangeOfString:ellipsis].location];
|
||
}
|
||
}
|
||
|
||
return canUseTable && self.fTableView.numberOfSelectedRows > 0;
|
||
}
|
||
|
||
//remove all completed transfers item
|
||
if (action == @selector(clearCompleted:))
|
||
{
|
||
//append or remove ellipsis when needed
|
||
NSString *title = menuItem.title, *ellipsis = NSString.ellipsis;
|
||
if ([self.fDefaults boolForKey:@"WarningRemoveCompleted"])
|
||
{
|
||
if (![title hasSuffix:ellipsis])
|
||
{
|
||
menuItem.title = [title stringByAppendingEllipsis];
|
||
}
|
||
}
|
||
else
|
||
{
|
||
if ([title hasSuffix:ellipsis])
|
||
{
|
||
menuItem.title = [title substringToIndex:[title rangeOfString:ellipsis].location];
|
||
}
|
||
}
|
||
|
||
for (Torrent* torrent in self.fTorrents)
|
||
{
|
||
if (torrent.finishedSeeding)
|
||
{
|
||
return YES;
|
||
}
|
||
}
|
||
return NO;
|
||
}
|
||
|
||
//enable pause all item
|
||
if (action == @selector(stopAllTorrents:))
|
||
{
|
||
for (Torrent* torrent in self.fTorrents)
|
||
{
|
||
if (torrent.active || torrent.waitingToStart)
|
||
{
|
||
return YES;
|
||
}
|
||
}
|
||
return NO;
|
||
}
|
||
|
||
//enable resume all item
|
||
if (action == @selector(resumeAllTorrents:))
|
||
{
|
||
for (Torrent* torrent in self.fTorrents)
|
||
{
|
||
if (!torrent.active && !torrent.waitingToStart && !torrent.finishedSeeding)
|
||
{
|
||
return YES;
|
||
}
|
||
}
|
||
return NO;
|
||
}
|
||
|
||
//enable resume all waiting item
|
||
if (action == @selector(resumeWaitingTorrents:))
|
||
{
|
||
if (![self.fDefaults boolForKey:@"Queue"] && ![self.fDefaults boolForKey:@"QueueSeed"])
|
||
{
|
||
return NO;
|
||
}
|
||
|
||
for (Torrent* torrent in self.fTorrents)
|
||
{
|
||
if (torrent.waitingToStart)
|
||
{
|
||
return YES;
|
||
}
|
||
}
|
||
return NO;
|
||
}
|
||
|
||
//enable resume selected waiting item
|
||
if (action == @selector(resumeSelectedTorrentsNoWait:))
|
||
{
|
||
if (!canUseTable)
|
||
{
|
||
return NO;
|
||
}
|
||
|
||
for (Torrent* torrent in self.fTableView.selectedTorrents)
|
||
{
|
||
if (!torrent.active)
|
||
{
|
||
return YES;
|
||
}
|
||
}
|
||
return NO;
|
||
}
|
||
|
||
//enable pause item
|
||
if (action == @selector(stopSelectedTorrents:))
|
||
{
|
||
if (!canUseTable)
|
||
{
|
||
return NO;
|
||
}
|
||
|
||
for (Torrent* torrent in self.fTableView.selectedTorrents)
|
||
{
|
||
if (torrent.active || torrent.waitingToStart)
|
||
{
|
||
return YES;
|
||
}
|
||
}
|
||
return NO;
|
||
}
|
||
|
||
//enable resume item
|
||
if (action == @selector(resumeSelectedTorrents:))
|
||
{
|
||
if (!canUseTable)
|
||
{
|
||
return NO;
|
||
}
|
||
|
||
for (Torrent* torrent in self.fTableView.selectedTorrents)
|
||
{
|
||
if (!torrent.active && !torrent.waitingToStart)
|
||
{
|
||
return YES;
|
||
}
|
||
}
|
||
return NO;
|
||
}
|
||
|
||
//enable manual announce item
|
||
if (action == @selector(announceSelectedTorrents:))
|
||
{
|
||
if (!canUseTable)
|
||
{
|
||
return NO;
|
||
}
|
||
|
||
for (Torrent* torrent in self.fTableView.selectedTorrents)
|
||
{
|
||
if (torrent.canManualAnnounce)
|
||
{
|
||
return YES;
|
||
}
|
||
}
|
||
return NO;
|
||
}
|
||
|
||
//enable reset cache item
|
||
if (action == @selector(verifySelectedTorrents:))
|
||
{
|
||
if (!canUseTable)
|
||
{
|
||
return NO;
|
||
}
|
||
|
||
for (Torrent* torrent in self.fTableView.selectedTorrents)
|
||
{
|
||
if (!torrent.magnet)
|
||
{
|
||
return YES;
|
||
}
|
||
}
|
||
return NO;
|
||
}
|
||
|
||
//enable move torrent file item
|
||
if (action == @selector(moveDataFilesSelected:))
|
||
{
|
||
return canUseTable && self.fTableView.numberOfSelectedRows > 0;
|
||
}
|
||
|
||
//enable copy torrent file item
|
||
if (action == @selector(copyTorrentFiles:))
|
||
{
|
||
if (!canUseTable)
|
||
{
|
||
return NO;
|
||
}
|
||
|
||
for (Torrent* torrent in self.fTableView.selectedTorrents)
|
||
{
|
||
if (!torrent.magnet)
|
||
{
|
||
return YES;
|
||
}
|
||
}
|
||
return NO;
|
||
}
|
||
|
||
//enable copy torrent file item
|
||
if (action == @selector(copyMagnetLinks:))
|
||
{
|
||
return canUseTable && self.fTableView.numberOfSelectedRows > 0;
|
||
}
|
||
|
||
//enable reverse sort item
|
||
if (action == @selector(setSortReverse:))
|
||
{
|
||
BOOL const isReverse = menuItem.tag == SortOrderTagDescending;
|
||
menuItem.state = (isReverse == [self.fDefaults boolForKey:@"SortReverse"]) ? NSControlStateValueOn : NSControlStateValueOff;
|
||
return ![[self.fDefaults stringForKey:@"Sort"] isEqualToString:SortTypeOrder];
|
||
}
|
||
|
||
//enable group sort item
|
||
if (action == @selector(setSortByGroup:))
|
||
{
|
||
menuItem.state = [self.fDefaults boolForKey:@"SortByGroup"] ? NSControlStateValueOn : NSControlStateValueOff;
|
||
return YES;
|
||
}
|
||
|
||
if (action == @selector(toggleQuickLook:))
|
||
{
|
||
BOOL const visible = [QLPreviewPanel sharedPreviewPanelExists] && [QLPreviewPanel sharedPreviewPanel].visible;
|
||
//text consistent with Finder
|
||
NSString* title = !visible ? NSLocalizedString(@"Quick Look", "View menu -> Quick Look") :
|
||
NSLocalizedString(@"Close Quick Look", "View menu -> Quick Look");
|
||
menuItem.title = title;
|
||
|
||
return self.fTableView.numberOfSelectedRows > 0;
|
||
}
|
||
|
||
return YES;
|
||
}
|
||
|
||
- (void)systemWillSleep
|
||
{
|
||
//stop all transfers (since some are active) before going to sleep and remember to resume when we wake up
|
||
BOOL anyActive = NO;
|
||
for (Torrent* torrent in self.fTorrents)
|
||
{
|
||
if (torrent.active)
|
||
{
|
||
anyActive = YES;
|
||
}
|
||
[torrent sleep]; //have to call on all, regardless if they are active
|
||
}
|
||
|
||
//if there are any running transfers, wait 15 seconds for them to stop
|
||
if (anyActive)
|
||
{
|
||
sleep(15);
|
||
}
|
||
}
|
||
|
||
- (void)systemDidWakeUp
|
||
{
|
||
//resume sleeping transfers after we wake up
|
||
for (Torrent* torrent in self.fTorrents)
|
||
{
|
||
[torrent wakeUp];
|
||
}
|
||
}
|
||
|
||
- (NSMenu*)applicationDockMenu:(NSApplication*)sender
|
||
{
|
||
if (self.fQuitting)
|
||
{
|
||
return nil;
|
||
}
|
||
|
||
NSUInteger seeding = 0, downloading = 0;
|
||
for (Torrent* torrent in self.fTorrents)
|
||
{
|
||
if (torrent.seeding)
|
||
{
|
||
seeding++;
|
||
}
|
||
else if (torrent.active)
|
||
{
|
||
downloading++;
|
||
}
|
||
}
|
||
|
||
NSMenu* menu = [[NSMenu alloc] init];
|
||
|
||
if (seeding > 0)
|
||
{
|
||
NSString* title = [NSString localizedStringWithFormat:NSLocalizedString(@"%lu Seeding", "Dock item - Seeding"), seeding];
|
||
[menu addItemWithTitle:title action:nil keyEquivalent:@""];
|
||
}
|
||
|
||
if (downloading > 0)
|
||
{
|
||
NSString* title = [NSString localizedStringWithFormat:NSLocalizedString(@"%lu Downloading", "Dock item - Downloading"), downloading];
|
||
[menu addItemWithTitle:title action:nil keyEquivalent:@""];
|
||
}
|
||
|
||
if (seeding > 0 || downloading > 0)
|
||
{
|
||
[menu addItem:[NSMenuItem separatorItem]];
|
||
}
|
||
|
||
[menu addItemWithTitle:NSLocalizedString(@"Pause All", "Dock item") action:@selector(stopAllTorrents:) keyEquivalent:@""];
|
||
[menu addItemWithTitle:NSLocalizedString(@"Resume All", "Dock item") action:@selector(resumeAllTorrents:) keyEquivalent:@""];
|
||
[menu addItem:[NSMenuItem separatorItem]];
|
||
[menu addItemWithTitle:NSLocalizedString(@"Speed Limit", "Dock item") action:@selector(toggleSpeedLimit:) keyEquivalent:@""];
|
||
|
||
return menu;
|
||
}
|
||
|
||
- (void)updateMainWindow
|
||
{
|
||
if (self.fStatusBar == nil)
|
||
{
|
||
self.fStatusBar = [[StatusBarController alloc] initWithLib:self.fLib];
|
||
self.fStatusBar.layoutAttribute = NSLayoutAttributeBottom;
|
||
self.fStatusBar.automaticallyAdjustsSize = NO;
|
||
|
||
[self.fWindow addTitlebarAccessoryViewController:self.fStatusBar];
|
||
}
|
||
|
||
if ([self.fDefaults boolForKey:@"StatusBar"])
|
||
{
|
||
self.fStatusBar.hidden = NO;
|
||
}
|
||
else
|
||
{
|
||
self.fStatusBar.hidden = YES;
|
||
}
|
||
|
||
if (self.fFilterBar == nil)
|
||
{
|
||
self.fFilterBar = [[FilterBarController alloc] init];
|
||
self.fFilterBar.layoutAttribute = NSLayoutAttributeBottom;
|
||
self.fFilterBar.automaticallyAdjustsSize = NO;
|
||
|
||
[self.fWindow addTitlebarAccessoryViewController:self.fFilterBar];
|
||
}
|
||
|
||
if ([self.fDefaults boolForKey:@"FilterBar"])
|
||
{
|
||
self.fFilterBar.hidden = NO;
|
||
[self focusFilterField];
|
||
}
|
||
else
|
||
{
|
||
self.fFilterBar.hidden = YES;
|
||
}
|
||
|
||
[self fullUpdateUI];
|
||
[self updateForAutoSize];
|
||
}
|
||
|
||
- (void)setWindowSizeToFit
|
||
{
|
||
if (!self.isFullScreen)
|
||
{
|
||
NSScrollView* scrollView = self.fTableView.enclosingScrollView;
|
||
|
||
scrollView.hasVerticalScroller = NO;
|
||
|
||
[self removeHeightConstraints];
|
||
|
||
if (![self.fDefaults boolForKey:@"AutoSize"])
|
||
{
|
||
// Only set a minimum height constraint
|
||
CGFloat height = self.minScrollViewHeightAllowed;
|
||
if (self.fMinHeightConstraint == nil)
|
||
{
|
||
self.fMinHeightConstraint = [scrollView.heightAnchor constraintGreaterThanOrEqualToConstant:height];
|
||
}
|
||
else
|
||
{
|
||
self.fMinHeightConstraint.constant = height;
|
||
}
|
||
|
||
self.fMinHeightConstraint.active = YES;
|
||
}
|
||
else
|
||
{
|
||
// Set a fixed height constraint
|
||
CGFloat height = [self calculateScrollViewHeightWithDockAdjustment];
|
||
if (self.fFixedHeightConstraint == nil)
|
||
{
|
||
self.fFixedHeightConstraint = [scrollView.heightAnchor constraintEqualToConstant:height];
|
||
}
|
||
else
|
||
{
|
||
self.fFixedHeightConstraint.constant = height;
|
||
}
|
||
|
||
// Redraw table to avoid empty cells
|
||
[self.fTableView reloadData];
|
||
|
||
self.fFixedHeightConstraint.active = YES;
|
||
}
|
||
|
||
scrollView.hasVerticalScroller = YES;
|
||
}
|
||
else
|
||
{
|
||
[self removeHeightConstraints];
|
||
}
|
||
}
|
||
|
||
- (CGFloat)calculateScrollViewHeightWithDockAdjustment
|
||
{
|
||
CGFloat height = self.scrollViewHeight;
|
||
|
||
// Get the main screen's visible frame
|
||
NSScreen* screen = self.fWindow.screen;
|
||
if (screen)
|
||
{
|
||
// This frame respects the Dock and menu bar
|
||
NSRect visibleFrame = screen.visibleFrame;
|
||
height = MIN(height, visibleFrame.size.height - [self toolbarHeight] - [self mainWindowComponentHeight]);
|
||
}
|
||
|
||
return height;
|
||
}
|
||
|
||
- (void)updateForAutoSize
|
||
{
|
||
if (!self.isFullScreen)
|
||
{
|
||
[self setWindowSizeToFit];
|
||
}
|
||
else
|
||
{
|
||
[self removeHeightConstraints];
|
||
}
|
||
}
|
||
|
||
- (void)updateWindowAfterToolbarChange
|
||
{
|
||
//Hacky way of fixing an issue with showing the Toolbar
|
||
if (!self.isFullScreen)
|
||
{
|
||
//macOS shows the unified toolbar by default
|
||
//and we only need to "fix" the layout when showing the toolbar
|
||
if (!self.fWindow.toolbar.isVisible)
|
||
{
|
||
[self removeHeightConstraints];
|
||
}
|
||
|
||
//this fixes a macOS bug where on toggling the toolbar item bezels will show
|
||
[self hideToolBarBezels:YES];
|
||
|
||
dispatch_async(dispatch_get_main_queue(), ^{
|
||
[self updateForAutoSize];
|
||
[self hideToolBarBezels:NO];
|
||
});
|
||
}
|
||
}
|
||
|
||
- (void)hideToolBarBezels:(BOOL)hide
|
||
{
|
||
for (NSToolbarItem* item in self.fWindow.toolbar.items)
|
||
{
|
||
item.view.hidden = hide;
|
||
}
|
||
}
|
||
|
||
- (void)removeHeightConstraints
|
||
{
|
||
if (self.fFixedHeightConstraint != nil)
|
||
{
|
||
self.fFixedHeightConstraint.active = NO;
|
||
}
|
||
if (self.fMinHeightConstraint != nil)
|
||
{
|
||
self.fMinHeightConstraint.active = NO;
|
||
}
|
||
}
|
||
|
||
- (CGFloat)minScrollViewHeightAllowed
|
||
{
|
||
CGFloat contentMinHeight = self.fTableView.rowHeight + self.fTableView.intercellSpacing.height;
|
||
return contentMinHeight;
|
||
}
|
||
|
||
- (CGFloat)toolbarHeight
|
||
{
|
||
return self.fWindow.frame.size.height - [self.fWindow contentRectForFrameRect:self.fWindow.frame].size.height;
|
||
}
|
||
|
||
- (CGFloat)mainWindowComponentHeight
|
||
{
|
||
CGFloat height = kBottomBarHeight;
|
||
|
||
if (self.fStatusBar != nil && !self.fStatusBar.isHidden)
|
||
{
|
||
height += kStatusBarHeight;
|
||
}
|
||
|
||
if (self.fFilterBar != nil && !self.fFilterBar.isHidden)
|
||
{
|
||
height += kFilterBarHeight;
|
||
}
|
||
|
||
return height;
|
||
}
|
||
|
||
- (CGFloat)scrollViewHeight
|
||
{
|
||
CGFloat height;
|
||
CGFloat minHeight = self.minScrollViewHeightAllowed;
|
||
|
||
if ([self.fDefaults boolForKey:@"AutoSize"])
|
||
{
|
||
NSUInteger groups = ![self.fDisplayedTorrents.firstObject isKindOfClass:[Torrent class]] ? self.fDisplayedTorrents.count : 0;
|
||
|
||
height = (kGroupSeparatorHeight + self.fTableView.intercellSpacing.height) * groups +
|
||
(self.fTableView.rowHeight + self.fTableView.intercellSpacing.height) * (self.fTableView.numberOfRows - groups);
|
||
|
||
//account for group padding...
|
||
if (groups > 1)
|
||
{
|
||
height += (groups - 1) * 20;
|
||
}
|
||
}
|
||
else
|
||
{
|
||
height = NSHeight(self.fTableView.enclosingScrollView.frame);
|
||
}
|
||
|
||
//make sure we don't go bigger than the screen height
|
||
NSScreen* screen = self.fWindow.screen;
|
||
if (screen)
|
||
{
|
||
NSSize maxSize = screen.visibleFrame.size;
|
||
maxSize.height -= self.toolbarHeight;
|
||
maxSize.height -= self.mainWindowComponentHeight;
|
||
|
||
if (height > maxSize.height)
|
||
{
|
||
height = maxSize.height;
|
||
}
|
||
}
|
||
|
||
//make sure we don't have zero height
|
||
if (height < minHeight)
|
||
{
|
||
height = minHeight;
|
||
}
|
||
|
||
return height;
|
||
}
|
||
|
||
- (BOOL)isFullScreen
|
||
{
|
||
return (self.fWindow.styleMask & NSWindowStyleMaskFullScreen) == NSWindowStyleMaskFullScreen;
|
||
}
|
||
|
||
- (void)windowWillEnterFullScreen:(NSNotification*)notification
|
||
{
|
||
[self removeHeightConstraints];
|
||
}
|
||
|
||
- (void)windowDidExitFullScreen:(NSNotification*)notification
|
||
{
|
||
[self updateForAutoSize];
|
||
}
|
||
|
||
- (void)updateForExpandCollapse
|
||
{
|
||
[self setWindowSizeToFit];
|
||
[self setBottomCountText:YES];
|
||
}
|
||
|
||
- (void)showMainWindow:(id)sender
|
||
{
|
||
[self.fWindow makeKeyAndOrderFront:nil];
|
||
}
|
||
|
||
- (void)windowDidBecomeMain:(NSNotification*)notification
|
||
{
|
||
[self.fBadger clearCompleted];
|
||
[self updateUI];
|
||
}
|
||
|
||
- (void)applicationWillUnhide:(NSNotification*)notification
|
||
{
|
||
[self updateUI];
|
||
}
|
||
|
||
- (void)toggleQuickLook:(id)sender
|
||
{
|
||
if ([QLPreviewPanel sharedPreviewPanel].visible)
|
||
{
|
||
[[QLPreviewPanel sharedPreviewPanel] orderOut:nil];
|
||
}
|
||
else
|
||
{
|
||
[[QLPreviewPanel sharedPreviewPanel] makeKeyAndOrderFront:nil];
|
||
}
|
||
}
|
||
|
||
- (void)linkHomepage:(id)sender
|
||
{
|
||
[NSWorkspace.sharedWorkspace openURL:[NSURL URLWithString:kWebsiteURL]];
|
||
}
|
||
|
||
- (void)linkForums:(id)sender
|
||
{
|
||
[NSWorkspace.sharedWorkspace openURL:[NSURL URLWithString:kForumURL]];
|
||
}
|
||
|
||
- (void)linkGitHub:(id)sender
|
||
{
|
||
[NSWorkspace.sharedWorkspace openURL:[NSURL URLWithString:kGithubURL]];
|
||
}
|
||
|
||
- (void)linkDonate:(id)sender
|
||
{
|
||
[NSWorkspace.sharedWorkspace openURL:[NSURL URLWithString:kDonateURL]];
|
||
}
|
||
|
||
- (void)rpcCallback:(tr_rpc_callback_type)type forTorrentStruct:(struct tr_torrent*)torrentStruct
|
||
{
|
||
@autoreleasepool
|
||
{
|
||
//get the torrent
|
||
__block Torrent* torrent = nil;
|
||
if (torrentStruct != NULL && (type != TR_RPC_TORRENT_ADDED && type != TR_RPC_SESSION_CHANGED && type != TR_RPC_SESSION_CLOSE))
|
||
{
|
||
[self.fTorrents enumerateObjectsWithOptions:NSEnumerationConcurrent
|
||
usingBlock:^(Torrent* checkTorrent, NSUInteger /*idx*/, BOOL* stop) {
|
||
if (torrentStruct == checkTorrent.torrentStruct)
|
||
{
|
||
torrent = checkTorrent;
|
||
*stop = YES;
|
||
}
|
||
}];
|
||
|
||
if (!torrent)
|
||
{
|
||
NSLog(@"No torrent found matching the given torrent struct from the RPC callback!");
|
||
return;
|
||
}
|
||
}
|
||
|
||
dispatch_async(dispatch_get_main_queue(), ^{
|
||
switch (type)
|
||
{
|
||
case TR_RPC_TORRENT_ADDED:
|
||
[self rpcAddTorrentStruct:torrentStruct];
|
||
break;
|
||
|
||
case TR_RPC_TORRENT_STARTED:
|
||
case TR_RPC_TORRENT_STOPPED:
|
||
[self rpcStartedStoppedTorrent:torrent];
|
||
break;
|
||
|
||
case TR_RPC_TORRENT_REMOVING:
|
||
[self rpcRemoveTorrent:torrent deleteData:NO];
|
||
break;
|
||
|
||
case TR_RPC_TORRENT_TRASHING:
|
||
[self rpcRemoveTorrent:torrent deleteData:YES];
|
||
break;
|
||
|
||
case TR_RPC_TORRENT_CHANGED:
|
||
[self rpcChangedTorrent:torrent];
|
||
break;
|
||
|
||
case TR_RPC_TORRENT_MOVED:
|
||
[self rpcMovedTorrent:torrent];
|
||
break;
|
||
|
||
case TR_RPC_SESSION_QUEUE_POSITIONS_CHANGED:
|
||
[self rpcUpdateQueue];
|
||
break;
|
||
|
||
case TR_RPC_SESSION_CHANGED:
|
||
[self.prefsController rpcUpdatePrefs];
|
||
break;
|
||
|
||
case TR_RPC_SESSION_CLOSE:
|
||
self.fQuitRequested = YES;
|
||
[NSApp terminate:self];
|
||
break;
|
||
|
||
default:
|
||
NSAssert1(NO, @"Unknown RPC command received: %d", type);
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
- (void)rpcAddTorrentStruct:(struct tr_torrent*)torrentStruct
|
||
{
|
||
NSString* location = nil;
|
||
if (tr_torrentGetDownloadDir(torrentStruct) != NULL)
|
||
{
|
||
location = @(tr_torrentGetDownloadDir(torrentStruct));
|
||
}
|
||
|
||
Torrent* torrent = [[Torrent alloc] initWithTorrentStruct:torrentStruct location:location lib:self.fLib];
|
||
|
||
//change the location if the group calls for it (this has to wait until after the torrent is created)
|
||
if ([GroupsController.groups usesCustomDownloadLocationForIndex:torrent.groupValue])
|
||
{
|
||
location = [GroupsController.groups customDownloadLocationForIndex:torrent.groupValue];
|
||
[torrent changeDownloadFolderBeforeUsing:location determinationType:TorrentDeterminationAutomatic];
|
||
}
|
||
|
||
[torrent update];
|
||
[self.fTorrents addObject:torrent];
|
||
|
||
if (!self.fAddingTransfers)
|
||
{
|
||
self.fAddingTransfers = [[NSMutableSet alloc] init];
|
||
}
|
||
[self.fAddingTransfers addObject:torrent];
|
||
|
||
[self fullUpdateUI];
|
||
}
|
||
|
||
- (void)rpcRemoveTorrent:(Torrent*)torrent deleteData:(BOOL)deleteData
|
||
{
|
||
[self confirmRemoveTorrents:@[ torrent ] deleteData:deleteData];
|
||
}
|
||
|
||
- (void)rpcStartedStoppedTorrent:(Torrent*)torrent
|
||
{
|
||
[torrent update];
|
||
|
||
[self updateUI];
|
||
[self applyFilter];
|
||
[self updateTorrentHistory];
|
||
}
|
||
|
||
- (void)rpcChangedTorrent:(Torrent*)torrent
|
||
{
|
||
[torrent update];
|
||
|
||
if ([self.fTableView.selectedTorrents containsObject:torrent])
|
||
{
|
||
[self.fInfoController updateInfoStats]; //this will reload the file table
|
||
[self.fInfoController updateOptions];
|
||
}
|
||
}
|
||
|
||
- (void)rpcMovedTorrent:(Torrent*)torrent
|
||
{
|
||
[torrent update];
|
||
[torrent updateTimeMachineExclude];
|
||
|
||
if ([self.fTableView.selectedTorrents containsObject:torrent])
|
||
{
|
||
[self.fInfoController updateInfoStats];
|
||
}
|
||
}
|
||
|
||
- (void)rpcUpdateQueue
|
||
{
|
||
for (Torrent* torrent in self.fTorrents)
|
||
{
|
||
[torrent update];
|
||
}
|
||
|
||
NSSortDescriptor* descriptor = [NSSortDescriptor sortDescriptorWithKey:@"queuePosition" ascending:YES];
|
||
NSArray* descriptors = @[ descriptor ];
|
||
[self.fTorrents sortUsingDescriptors:descriptors];
|
||
|
||
[self sortTorrentsAndIncludeQueueOrder:YES];
|
||
}
|
||
|
||
@end
|
||
|
||
@implementation Controller (SUUpdaterDelegate)
|
||
|
||
- (void)updaterWillRelaunchApplication:(SUUpdater*)updater
|
||
{
|
||
self.fQuitRequested = YES;
|
||
}
|
||
|
||
- (nullable id<SUVersionComparison>)versionComparatorForUpdater:(SUUpdater*)updater
|
||
{
|
||
return [VersionComparator new];
|
||
}
|
||
|
||
@end
|