From 6adf9d2dc58035624cfece2cc5575b8dd64ca74e Mon Sep 17 00:00:00 2001 From: Ankush Vangari Date: Sat, 9 May 2026 00:48:46 -0700 Subject: [PATCH 1/4] Add image clipboard support Flycut now captures, stores, displays, and pastes back images from the clipboard alongside text clippings. Images are stored as TIFF files on disk at ~/Library/Application Support/Flycut/Images/ using SHA-256 content hashing for deduplication. When both text and image are present on the clipboard, both are captured as separate entries. - FlycutImageStore: new singleton for file-based image storage - FlycutClipping: extended with imageHash, imageSize, isImageClipping - BezelWindow: added NSImageView for image thumbnail display - pollPB: detects NSPasteboardTypeTIFF/PNG alongside text - Paste-back works for images in bezel, menu, and search window - Save-to-file (S key) exports images as .tiff - Search matches image clippings by source app name - Preferences: "Capture images" toggle, 10MB max size default - Image files cleaned up when clippings are evicted from history Co-Authored-By: Claude Opus 4.6 (1M context) --- AppController.h | 1 + AppController.m | 197 +++++++++++++++++++++---------- Flycut.xcodeproj/project.pbxproj | 8 ++ FlycutEngine/FlycutClipping.h | 17 ++- FlycutEngine/FlycutClipping.m | 80 +++++++++++-- FlycutEngine/FlycutImageStore.h | 25 ++++ FlycutEngine/FlycutImageStore.m | 124 +++++++++++++++++++ FlycutEngine/FlycutStore.h | 1 + FlycutEngine/FlycutStore.m | 50 ++++++-- FlycutOperator.h | 2 + FlycutOperator.m | 144 ++++++++++++++++++---- UI/BezelWindow.h | 4 + UI/BezelWindow.m | 38 +++++- 13 files changed, 577 insertions(+), 114 deletions(-) create mode 100644 FlycutEngine/FlycutImageStore.h create mode 100644 FlycutEngine/FlycutImageStore.m diff --git a/AppController.h b/AppController.h index 669bed8..4d85a7e 100755 --- a/AppController.h +++ b/AppController.h @@ -91,6 +91,7 @@ // Basic functionality -(void) pollPB:(NSTimer *)timer; -(void) addClipToPasteboard:(NSString*)pbFullText; +-(void) addImageClipToPasteboard:(NSString *)imageHash; -(void) setPBBlockCount:(NSNumber *)newPBBlockCount; -(void) hideApp; -(void) fakeCommandV; diff --git a/AppController.m b/AppController.m index 3898ab0..a12a836 100755 --- a/AppController.m +++ b/AppController.m @@ -18,6 +18,7 @@ #import "SRRecorderCell.h" #import "NSWindow+TrueCenter.h" #import "NSWindow+ULIZoomEffect.h" +#import "FlycutImageStore.h" //#import "MJCloudKitUserDefaultsSync/MJCloudKitUserDefaultsSync.h" #import #import @@ -94,6 +95,10 @@ - (id)init @"saveForgottenFavorites", [NSNumber numberWithBool:NO], @"suppressAccessibilityAlert", + [NSNumber numberWithBool:YES], + @"captureImages", + [NSNumber numberWithInt:10240], + @"maxImageSizeKB", nil]]; /* For testing, the ability to force initial values of the sync settings: @@ -765,6 +770,13 @@ -(void) buildAppearancesPreferencePanel action:@selector(setupBezel:)]; [appearancePanel addSubview:row]; nextYMax = row.frame.origin.y; + + row = [self preferencePanelCheckboxRowForText:@"Capture images from clipboard" + frameMaxY:nextYMax + binding:@"captureImages" + action:nil]; + [appearancePanel addSubview:row]; + nextYMax = row.frame.origin.y; // Add Accessibility Check button NSRect panelFrame = [appearancePanel frame]; @@ -882,17 +894,21 @@ - (void)restoreStashedStoreAndUpdate - (void)pasteFromStack { - NSLog(@"pasteFromStack called"); - NSString *content = [flycutOperator getPasteFromStackPosition]; - if ( nil != content ) { - NSLog(@"Content found, adding to pasteboard and preparing to paste: %@", [content substringToIndex:MIN(content.length, 50)]); - [self addClipToPasteboard:content]; - [self performSelector:@selector(hideApp) withObject:nil afterDelay:0.2]; - [self performSelector:@selector(fakeCommandV) withObject:nil afterDelay:0.5]; - } else { - NSLog(@"No content found in stack position"); - [self performSelector:@selector(hideApp) withObject:nil afterDelay:0.2]; - } + FlycutClipping *clipping = [flycutOperator clippingAtStackPosition]; + if (clipping) { + if ([clipping isImageClipping]) { + [self addImageClipToPasteboard:[clipping imageHash]]; + } else { + NSString *content = [flycutOperator getPasteFromStackPosition]; + if (content) { + [self addClipToPasteboard:content]; + } + } + [self performSelector:@selector(hideApp) withObject:nil afterDelay:0.2]; + [self performSelector:@selector(fakeCommandV) withObject:nil afterDelay:0.5]; + } else { + [self performSelector:@selector(hideApp) withObject:nil afterDelay:0.2]; + } [self restoreStashedStoreAndUpdate]; } @@ -907,7 +923,6 @@ - (void)moveItemAtStackPositionToTopOfStack } - (void)pasteIndexAndUpdate:(int) position { - // If there is an active search, we need to map the menu index to the stack position. NSString* search = [searchBox stringValue]; if ( nil != search && 0 != search.length ) { @@ -915,12 +930,21 @@ - (void)pasteIndexAndUpdate:(int) position { position = [mapping[position] intValue]; } - NSString *content = [flycutOperator getPasteFromIndex: position]; - if ( nil != content ) - { - [self addClipToPasteboard:content]; + FlycutClipping *clipping = [flycutOperator clippingAtIndex:position]; + if (clipping && [clipping isImageClipping]) { + [self addImageClipToPasteboard:[clipping imageHash]]; + if ([[NSUserDefaults standardUserDefaults] boolForKey:@"pasteMovesToTop"]) { + [flycutOperator getPasteFromIndex:position]; + } [self updateMenu]; - } + } else { + NSString *content = [flycutOperator getPasteFromIndex: position]; + if ( nil != content ) + { + [self addClipToPasteboard:content]; + [self updateMenu]; + } + } } - (void)metaKeysReleased @@ -1066,48 +1090,67 @@ -(BOOL)control:(NSControl *)control textView:(NSTextView *)fieldEditor doCommand -(void)pollPB:(NSTimer *)timer { - NSString *type = [jcPasteboard availableTypeFromArray:[NSArray arrayWithObject:NSPasteboardTypeString]]; + NSString *textType = [jcPasteboard availableTypeFromArray:@[NSPasteboardTypeString]]; + NSString *imageType = [jcPasteboard availableTypeFromArray:@[NSPasteboardTypeTIFF, NSPasteboardTypePNG]]; + if ( [pbCount intValue] != [jcPasteboard changeCount] && ![flycutOperator storeDisabled] ) { - // Reload pbCount with the current changeCount - // Probably poor coding technique, but pollPB should be the only thing messing with pbCount, so it should be okay [pbCount release]; pbCount = [[NSNumber numberWithInt:[jcPasteboard changeCount]] retain]; - if ( type != nil ) { - NSRunningApplication *currRunningApp = nil; - for (NSRunningApplication *currApp in [[NSWorkspace sharedWorkspace] runningApplications]) - if ([currApp isActive]) - currRunningApp = currApp; - bool largeCopyRisk = nil != currRunningApp && [[currRunningApp localizedName] rangeOfString:@"Remote Desktop Connection"].location != NSNotFound; - // Microsoft's Remote Desktop Connection has an issue with large copy actions, which appears to be in the time it takes to transer them over the network. The copy starts being registered with OS X prior to completion of the transfer, and if the active application changes during the transfer the copy will be lost. Indicate this time period by toggling the menu icon at the beginning of all RDC trasfers and back at the end. Apple's Screen Sharing does not demonstrate this problem. + NSRunningApplication *currRunningApp = nil; + for (NSRunningApplication *currApp in [[NSWorkspace sharedWorkspace] runningApplications]) + if ([currApp isActive]) + currRunningApp = currApp; + + if ( textType != nil ) { + bool largeCopyRisk = nil != currRunningApp && [[currRunningApp localizedName] rangeOfString:@"Remote Desktop Connection"].location != NSNotFound; if (largeCopyRisk) [self toggleMenuIconDisabled]; - // In case we need to do a status visual, this will be dispatched out so our thread isn't blocked. dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0); dispatch_async(queue, ^{ - - // This operation blocks until the transfer is complete, though it was was here before the RDC issue was discovered. Convenient. - NSString *contents = [jcPasteboard stringForType:type]; - - // Toggle back if dealing with the RDC issue. + NSString *contents = [jcPasteboard stringForType:textType]; if (largeCopyRisk) [self toggleMenuIconDisabled]; - if ( contents == nil || [flycutOperator shouldSkip:contents ofType:[jcPasteboard availableTypeFromArray:[NSArray arrayWithObject:NSPasteboardTypeString]] fromAvailableTypes:[jcPasteboard types]] ) { + if ( contents == nil || [flycutOperator shouldSkip:contents ofType:[jcPasteboard availableTypeFromArray:@[NSPasteboardTypeString]] fromAvailableTypes:[jcPasteboard types]] ) { DLog(@"Contents: Empty or skipped"); } else { - // Dispatch back to main queue to safely modify the clipping store and update UI. - // jcList (NSMutableArray) is not thread-safe, and concurrent access from this - // background queue and the main thread (e.g. showing the bezel) causes crashes. dispatch_async(dispatch_get_main_queue(), ^{ if ( ! [pbCount isEqualTo:pbBlockCount] ) { - [flycutOperator addClipping:contents ofType:type fromApp:[currRunningApp localizedName] withAppBundleURL:currRunningApp.bundleURL.path target:self clippingAddedSelector:@selector(updateMenu)]; + [flycutOperator addClipping:contents ofType:textType fromApp:[currRunningApp localizedName] withAppBundleURL:currRunningApp.bundleURL.path target:self clippingAddedSelector:@selector(updateMenu)]; } }); } }); - } + } + + if ( imageType != nil && [[NSUserDefaults standardUserDefaults] boolForKey:@"captureImages"] ) { + if ( [flycutOperator shouldSkip:@"" ofType:imageType fromAvailableTypes:[jcPasteboard types]] ) { + DLog(@"Image: skipped due to pasteboard type filter"); + } else { + dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0); + dispatch_async(queue, ^{ + NSData *imageData = [[jcPasteboard dataForType:NSPasteboardTypeTIFF] copy]; + if (imageData && [imageData length] > 0) { + int maxSizeKB = (int)[[NSUserDefaults standardUserDefaults] integerForKey:@"maxImageSizeKB"]; + if (maxSizeKB <= 0) maxSizeKB = 10240; + if ([imageData length] <= (NSUInteger)maxSizeKB * 1024) { + dispatch_async(dispatch_get_main_queue(), ^{ + if ( ! [pbCount isEqualTo:pbBlockCount] ) { + [flycutOperator addImageClipping:imageData fromApp:[currRunningApp localizedName] withAppBundleURL:currRunningApp.bundleURL.path target:self clippingAddedSelector:@selector(updateMenu)]; + } + [imageData release]; + }); + } else { + [imageData release]; + } + } else { + [imageData release]; + } + }); + } + } } } @@ -1527,8 +1570,12 @@ - (void)updateMenuContaining:(NSString*)search { keyEquivalent:@""]; [item setTarget:self]; [item setEnabled:YES]; + if ([[clipStrings objectAtIndex:i] hasPrefix:@"[Image"]) { + NSImage *imgIcon = [NSImage imageNamed:NSImageNameQuickLookTemplate]; + [imgIcon setSize:NSMakeSize(16, 16)]; + [item setImage:imgIcon]; + } [jcMenu insertItem:item atIndex:0]; - // Way back in 0.2, failure to release the new item here was causing a quite atrocious memory leak. [item release]; } }); @@ -1554,15 +1601,21 @@ -(void) setPBBlockCount:(NSNumber *)newPBBlockCount -(void)addClipToPasteboard:(NSString*)pbFullText { - NSArray *pbTypes; - pbTypes = [NSArray arrayWithObjects:@"NSStringPboardType",NULL]; - - [jcPasteboard declareTypes:pbTypes owner:NULL]; - - [jcPasteboard setString:pbFullText forType:@"NSStringPboardType"]; + [jcPasteboard declareTypes:@[NSPasteboardTypeString] owner:nil]; + [jcPasteboard setString:pbFullText forType:NSPasteboardTypeString]; [self setPBBlockCount:[NSNumber numberWithInt:[jcPasteboard changeCount]]]; } +-(void)addImageClipToPasteboard:(NSString *)imageHash +{ + NSData *imageData = [[FlycutImageStore sharedStore] imageDataForHash:imageHash]; + if (imageData) { + [jcPasteboard declareTypes:@[NSPasteboardTypeTIFF] owner:nil]; + [jcPasteboard setData:imageData forType:NSPasteboardTypeTIFF]; + [self setPBBlockCount:[NSNumber numberWithInt:[jcPasteboard changeCount]]]; + } +} + -(void) stackDown { NSLog(@"stackDown: current position=%d, total count=%d", [flycutOperator stackPosition], [flycutOperator jcListCount]); @@ -1577,15 +1630,24 @@ -(void) stackDown -(void) fillBezel { FlycutClipping* clipping = [flycutOperator clippingAtStackPosition]; - [bezel setText:[NSString stringWithFormat:@"%@", [clipping contents]]]; - + + if ([clipping isImageClipping]) { + NSData *imageData = [[FlycutImageStore sharedStore] imageDataForHash:[clipping imageHash]]; + if (imageData) { + NSImage *image = [[NSImage alloc] initWithData:imageData]; + [bezel setImage:image]; + [image release]; + } else { + [bezel setText:@"[Image not found]"]; + } + } else { + [bezel setText:[NSString stringWithFormat:@"%@", [clipping contents]]]; + } + int currentPos = [flycutOperator stackPosition] + 1; int totalCount = [flycutOperator jcListCount]; - int displayNum = [[NSUserDefaults standardUserDefaults] integerForKey:@"displayNum"]; - - NSLog(@"fillBezel: showing %d of %d (displayNum pref=%d)", currentPos, totalCount, displayNum); [bezel setCharString:[NSString stringWithFormat:@"%d of %d", currentPos, totalCount]]; - + NSString *localizedName = [clipping appLocalizedName]; if ( nil == localizedName ) localizedName = @""; @@ -1896,29 +1958,34 @@ - (IBAction)searchWindowItemSelected:(id)sender { NSInteger selectedRow = [searchWindowTableView selectedRow]; if (selectedRow < 0) { - selectedRow = 0; // Default to first item if none selected + selectedRow = 0; } - + if (selectedRow < [searchResults count]) { - // Get the content and paste it like bezel does NSString* searchText = [searchWindowSearchField stringValue]; NSArray *mapping = nil; int position = (int)selectedRow; - + if (searchText && [searchText length] > 0) { mapping = [flycutOperator previousIndexes:[[NSUserDefaults standardUserDefaults] integerForKey:@"displayNum"] containing:searchText]; position = [mapping[selectedRow] intValue]; } - - NSString *content = [flycutOperator getPasteFromIndex:position]; - if (content) { - [self addClipToPasteboard:content]; - [self updateMenu]; // Update menu like bezel does - [self hideSearchWindow]; - - // Always paste immediately (like bezel behavior), ignore menuSelectionPastes preference - [self performSelector:@selector(fakeCommandV) withObject:nil afterDelay:0.3]; + + FlycutClipping *clipping = [flycutOperator clippingAtIndex:position]; + if (clipping && [clipping isImageClipping]) { + [self addImageClipToPasteboard:[clipping imageHash]]; + if ([[NSUserDefaults standardUserDefaults] boolForKey:@"pasteMovesToTop"]) { + [flycutOperator getPasteFromIndex:position]; + } + } else { + NSString *content = [flycutOperator getPasteFromIndex:position]; + if (content) { + [self addClipToPasteboard:content]; + } } + [self updateMenu]; + [self hideSearchWindow]; + [self performSelector:@selector(fakeCommandV) withObject:nil afterDelay:0.3]; } } diff --git a/Flycut.xcodeproj/project.pbxproj b/Flycut.xcodeproj/project.pbxproj index defebf3..5bead28 100755 --- a/Flycut.xcodeproj/project.pbxproj +++ b/Flycut.xcodeproj/project.pbxproj @@ -67,6 +67,8 @@ E5334CA52DBF0A1D00AAEAEE /* FlycutClipping.m in Sources */ = {isa = PBXBuildFile; fileRef = E5334CA02DBF0A1D00AAEAEE /* FlycutClipping.m */; }; E5334CA62DBF0A1D00AAEAEE /* FlycutStore.h in Headers */ = {isa = PBXBuildFile; fileRef = E5334CA12DBF0A1D00AAEAEE /* FlycutStore.h */; }; E5334CA72DBF0A1D00AAEAEE /* FlycutClipping.h in Headers */ = {isa = PBXBuildFile; fileRef = E5334C9F2DBF0A1D00AAEAEE /* FlycutClipping.h */; }; + F1000001AAAA000100000001 /* FlycutImageStore.m in Sources */ = {isa = PBXBuildFile; fileRef = F1000001AAAA000100000003 /* FlycutImageStore.m */; }; + F1000001AAAA000100000002 /* FlycutImageStore.h in Headers */ = {isa = PBXBuildFile; fileRef = F1000001AAAA000100000004 /* FlycutImageStore.h */; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -172,6 +174,8 @@ E5334CA02DBF0A1D00AAEAEE /* FlycutClipping.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FlycutClipping.m; sourceTree = ""; }; E5334CA12DBF0A1D00AAEAEE /* FlycutStore.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = FlycutStore.h; sourceTree = ""; }; E5334CA22DBF0A1D00AAEAEE /* FlycutStore.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FlycutStore.m; sourceTree = ""; }; + F1000001AAAA000100000003 /* FlycutImageStore.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FlycutImageStore.m; sourceTree = ""; }; + F1000001AAAA000100000004 /* FlycutImageStore.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = FlycutImageStore.h; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -376,6 +380,8 @@ E5334CA02DBF0A1D00AAEAEE /* FlycutClipping.m */, E5334CA12DBF0A1D00AAEAEE /* FlycutStore.h */, E5334CA22DBF0A1D00AAEAEE /* FlycutStore.m */, + F1000001AAAA000100000004 /* FlycutImageStore.h */, + F1000001AAAA000100000003 /* FlycutImageStore.m */, ); path = FlycutEngine; sourceTree = ""; @@ -399,6 +405,7 @@ 7761C8AC139BDF12000FB3AB /* SRKeyCodeTransformer.h in Headers */, E5334CA62DBF0A1D00AAEAEE /* FlycutStore.h in Headers */, E5334CA72DBF0A1D00AAEAEE /* FlycutClipping.h in Headers */, + F1000001AAAA000100000002 /* FlycutImageStore.h in Headers */, 7761C8AE139BDF12000FB3AB /* SRRecorderCell.h in Headers */, 7761C8B0139BDF12000FB3AB /* SRRecorderControl.h in Headers */, 7761C8B2139BDF12000FB3AB /* SRValidator.h in Headers */, @@ -516,6 +523,7 @@ 77A4F3B0139BD72300F39666 /* SGHotKeyCenter.m in Sources */, E5334CA42DBF0A1D00AAEAEE /* FlycutStore.m in Sources */, E5334CA52DBF0A1D00AAEAEE /* FlycutClipping.m in Sources */, + F1000001AAAA000100000001 /* FlycutImageStore.m in Sources */, 77A4F3B2139BD72300F39666 /* SGKeyCodeTranslator.m in Sources */, 77A4F3B4139BD72300F39666 /* SGKeyCombo.m in Sources */, 7761C891139BDEAF000FB3AB /* BezelWindow.m in Sources */, diff --git a/FlycutEngine/FlycutClipping.h b/FlycutEngine/FlycutClipping.h index 81316b2..2fb98cf 100755 --- a/FlycutEngine/FlycutClipping.h +++ b/FlycutEngine/FlycutClipping.h @@ -12,26 +12,20 @@ #import @interface FlycutClipping : NSObject { -// What must a clipping hold? -// The text NSString * clipContents; -// The text type NSString * clipType; -// The display length int clipDisplayLength; -// The display string NSString * clipDisplayString; -// Does it have a name? BOOL clipHasName; -// The app name it came from NSString * appLocalizedName; -// The the bunle URL of the app it came from NSString * appBundleURL; -// The time NSInteger clipTimestamp; + NSString * imageHash; + NSSize imageSize; } -(id) initWithContents:(NSString *)contents withType:(NSString *)type withDisplayLength:(int)displayLength withAppLocalizedName:(NSString *)localizedName withAppBundleURL:(NSString *)bundleURL withTimestamp:(NSInteger)timestamp; +-(id) initWithImageHash:(NSString *)hash withImageSize:(NSSize)size withDisplayLength:(int)displayLength withAppLocalizedName:(NSString *)localizedName withAppBundleURL:(NSString *)bundleURL withTimestamp:(NSInteger)timestamp; /* -(id) initWithCoder:(NSCoder *)coder; -(void) decodeWithCoder:(NSCoder *)coder; */ -(NSString *) description; @@ -53,6 +47,11 @@ -(NSString *) appBundleURL; -(NSInteger) timestamp; -(BOOL) hasName; +-(BOOL) isImageClipping; +-(NSString *) imageHash; +-(NSSize) imageSize; +-(void) setImageHash:(NSString *)hash; +-(void) setImageSize:(NSSize)size; // Additional functions -(void) resetDisplayString; diff --git a/FlycutEngine/FlycutClipping.m b/FlycutEngine/FlycutClipping.m index 5997530..2e1ebb9 100755 --- a/FlycutEngine/FlycutClipping.m +++ b/FlycutEngine/FlycutClipping.m @@ -11,6 +11,7 @@ #import "FlycutClipping.h" +#import @implementation FlycutClipping @@ -31,6 +32,8 @@ -(id) initWithContents:(NSString *)contents withType:(NSString *)type withDispla clipContents = [[[NSString alloc] init] retain]; clipDisplayString = [[[NSString alloc] init] retain]; clipType = [[[NSString alloc] init] retain]; + imageHash = nil; + imageSize = NSZeroSize; [self setContents:contents setDisplayLength:displayLength]; [self setType:type]; @@ -38,7 +41,26 @@ -(id) initWithContents:(NSString *)contents withType:(NSString *)type withDispla [self setAppBundleURL:bundleURL]; [self setTimestamp:timestamp]; [self setHasName:false]; - + + return self; +} + +-(id) initWithImageHash:(NSString *)hash withImageSize:(NSSize)size withDisplayLength:(int)displayLength withAppLocalizedName:(NSString *)localizedName withAppBundleURL:(NSString*)bundleURL withTimestamp:(NSInteger)timestamp +{ + [super init]; + clipContents = [@"" retain]; + clipDisplayString = [[[NSString alloc] init] retain]; + clipType = [NSPasteboardTypeTIFF retain]; + clipDisplayLength = displayLength > 0 ? displayLength : 40; + + [self setImageHash:hash]; + imageSize = size; + [self setAppLocalizedName:localizedName]; + [self setAppBundleURL:bundleURL]; + [self setTimestamp:timestamp]; + [self setHasName:false]; + [self resetDisplayString]; + return self; } @@ -136,19 +158,31 @@ -(void) setHasName:(BOOL)newHasName -(void) resetDisplayString { + [clipDisplayString release]; + + if (imageHash != nil) { + NSString *newDisplayString; + if (imageSize.width > 0 && imageSize.height > 0) { + newDisplayString = [NSString stringWithFormat:@"[Image %dx%d]", + (int)imageSize.width, (int)imageSize.height]; + } else { + newDisplayString = @"[Image]"; + } + [newDisplayString retain]; + clipDisplayString = newDisplayString; + return; + } + NSString *newDisplayString, *firstLineOfClipping, *trimmedString; NSUInteger start, lineEnd, contentsEnd; NSRange startRange = NSMakeRange(0,0); NSRange contentsRange; - // We're resetting the display string, so release the old one. - [clipDisplayString release]; - // We want to restrict the display string to the clipping contents through the first line break. trimmedString = [clipContents stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]; [trimmedString getLineStart:&start end:&lineEnd contentsEnd:&contentsEnd forRange:startRange]; contentsRange = NSMakeRange(0, contentsEnd); firstLineOfClipping = [trimmedString substringWithRange:contentsRange]; if ( [firstLineOfClipping length] > clipDisplayLength ) { - newDisplayString = [[NSString stringWithString:[firstLineOfClipping substringToIndex:clipDisplayLength]] stringByAppendingString:@"…"]; + newDisplayString = [[NSString stringWithString:[firstLineOfClipping substringToIndex:clipDisplayLength]] stringByAppendingString:@"…"]; } else { newDisplayString = [NSString stringWithString:firstLineOfClipping]; } @@ -216,14 +250,45 @@ -(BOOL) hasName return clipHasName; } +-(BOOL) isImageClipping +{ + return imageHash != nil; +} + +-(NSString *) imageHash +{ + return imageHash; +} + +-(NSSize) imageSize +{ + return imageSize; +} + +-(void) setImageHash:(NSString *)hash +{ + id old = imageHash; + [hash retain]; + imageHash = hash; + [old release]; +} + +-(void) setImageSize:(NSSize)size +{ + imageSize = size; +} + - (BOOL)isEqual:(id)other { if (other == self) return YES; if (!other || ![other isKindOfClass:[self class]]) return NO; FlycutClipping * otherClip = (FlycutClipping *)other; - return (/*[self.type isEqualToString:otherClip.type] &&*/ // Type is under-utilized a this time and will mismatch on cross-device (macOS <-> iOS) usage. This should be revisited once we have support for more than just raw text clippings. - [self.contents isEqualToString:otherClip.contents]); + if ([self isImageClipping] && [otherClip isImageClipping]) + return [self.imageHash isEqualToString:otherClip.imageHash]; + if ([self isImageClipping] != [otherClip isImageClipping]) + return NO; + return [self.contents isEqualToString:otherClip.contents]; } @@ -234,6 +299,7 @@ -(void) dealloc [clipType release]; [appLocalizedName release]; [appBundleURL release]; + [imageHash release]; clipDisplayLength = 0; [clipDisplayString release]; clipHasName = 0; diff --git a/FlycutEngine/FlycutImageStore.h b/FlycutEngine/FlycutImageStore.h new file mode 100644 index 0000000..829a0ac --- /dev/null +++ b/FlycutEngine/FlycutImageStore.h @@ -0,0 +1,25 @@ +// +// FlycutImageStore.h +// Flycut +// +// File-based image storage for clipboard image clippings. +// + +#import +#import + +@interface FlycutImageStore : NSObject { + NSString *imagesDirectoryPath; +} + ++(FlycutImageStore *)sharedStore; + +-(NSString *)hashForData:(NSData *)data; +-(BOOL)saveImageData:(NSData *)data forHash:(NSString *)hash; +-(NSData *)imageDataForHash:(NSString *)hash; +-(void)deleteImageForHash:(NSString *)hash; +-(BOOL)hasImageForHash:(NSString *)hash; +-(NSString *)imagesDirectoryPath; +-(NSArray *)allStoredHashes; + +@end diff --git a/FlycutEngine/FlycutImageStore.m b/FlycutEngine/FlycutImageStore.m new file mode 100644 index 0000000..683be39 --- /dev/null +++ b/FlycutEngine/FlycutImageStore.m @@ -0,0 +1,124 @@ +// +// FlycutImageStore.m +// Flycut +// +// File-based image storage for clipboard image clippings. +// + +#import "FlycutImageStore.h" + +static FlycutImageStore *sharedInstance = nil; + +@implementation FlycutImageStore + ++(FlycutImageStore *)sharedStore +{ + if (nil == sharedInstance) { + sharedInstance = [[FlycutImageStore alloc] init]; + } + return sharedInstance; +} + +-(id)init +{ + self = [super init]; + if (self) { + NSArray *paths = NSSearchPathForDirectoriesInDomains(NSApplicationSupportDirectory, NSUserDomainMask, YES); + NSString *appSupportDir = [paths firstObject]; + imagesDirectoryPath = [[appSupportDir stringByAppendingPathComponent:@"Flycut/Images"] retain]; + + NSFileManager *fm = [NSFileManager defaultManager]; + if (![fm fileExistsAtPath:imagesDirectoryPath]) { + [fm createDirectoryAtPath:imagesDirectoryPath + withIntermediateDirectories:YES + attributes:nil + error:nil]; + } + } + return self; +} + +-(NSString *)hashForData:(NSData *)data +{ + if (!data || [data length] == 0) + return nil; + + unsigned char hash[CC_SHA256_DIGEST_LENGTH]; + CC_SHA256([data bytes], (CC_LONG)[data length], hash); + NSMutableString *hashString = [NSMutableString stringWithCapacity:CC_SHA256_DIGEST_LENGTH * 2]; + for (int i = 0; i < CC_SHA256_DIGEST_LENGTH; i++) + [hashString appendFormat:@"%02x", hash[i]]; + return hashString; +} + +-(NSString *)filePathForHash:(NSString *)hash +{ + return [imagesDirectoryPath stringByAppendingPathComponent: + [NSString stringWithFormat:@"%@.tiff", hash]]; +} + +-(BOOL)saveImageData:(NSData *)data forHash:(NSString *)hash +{ + if (!data || !hash) + return NO; + + NSString *filePath = [self filePathForHash:hash]; + if ([[NSFileManager defaultManager] fileExistsAtPath:filePath]) + return YES; + + return [data writeToFile:filePath atomically:YES]; +} + +-(NSData *)imageDataForHash:(NSString *)hash +{ + if (!hash) + return nil; + + NSString *filePath = [self filePathForHash:hash]; + if (![[NSFileManager defaultManager] fileExistsAtPath:filePath]) + return nil; + + return [NSData dataWithContentsOfFile:filePath]; +} + +-(void)deleteImageForHash:(NSString *)hash +{ + if (!hash) + return; + + NSString *filePath = [self filePathForHash:hash]; + [[NSFileManager defaultManager] removeItemAtPath:filePath error:nil]; +} + +-(BOOL)hasImageForHash:(NSString *)hash +{ + if (!hash) + return NO; + + return [[NSFileManager defaultManager] fileExistsAtPath:[self filePathForHash:hash]]; +} + +-(NSString *)imagesDirectoryPath +{ + return imagesDirectoryPath; +} + +-(NSArray *)allStoredHashes +{ + NSArray *files = [[NSFileManager defaultManager] contentsOfDirectoryAtPath:imagesDirectoryPath error:nil]; + NSMutableArray *hashes = [NSMutableArray arrayWithCapacity:[files count]]; + for (NSString *file in files) { + if ([file hasSuffix:@".tiff"]) { + [hashes addObject:[file stringByDeletingPathExtension]]; + } + } + return hashes; +} + +-(void)dealloc +{ + [imagesDirectoryPath release]; + [super dealloc]; +} + +@end diff --git a/FlycutEngine/FlycutStore.h b/FlycutEngine/FlycutStore.h index f0d1bdc..28dd8d2 100755 --- a/FlycutEngine/FlycutStore.h +++ b/FlycutEngine/FlycutStore.h @@ -87,6 +87,7 @@ // Add a clipping -(bool) addClipping:(NSString *)clipping ofType:(NSString *)type fromAppLocalizedName:(NSString *)appLocalizedName fromAppBundleURL:(NSString *)bundleURL atTimestamp:(NSInteger) timestamp; +-(bool) addImageClippingWithHash:(NSString *)hash imageSize:(NSSize)size fromAppLocalizedName:(NSString *)appLocalizedName fromAppBundleURL:(NSString *)bundleURL atTimestamp:(NSInteger)timestamp; -(void) addClipping:(FlycutClipping*) clipping; -(void) insertClipping:(FlycutClipping*) clipping atIndex:(int) index; diff --git a/FlycutEngine/FlycutStore.m b/FlycutEngine/FlycutStore.m index 68aab09..6c4e547 100755 --- a/FlycutEngine/FlycutStore.m +++ b/FlycutEngine/FlycutStore.m @@ -95,6 +95,22 @@ -(bool) addClipping:(NSString *)clipping ofType:(NSString *)type fromAppLocalize return YES; } +-(bool) addImageClippingWithHash:(NSString *)hash imageSize:(NSSize)size fromAppLocalizedName:(NSString *)appLocalizedName fromAppBundleURL:(NSString *)bundleURL atTimestamp:(NSInteger)timestamp +{ + if (!hash || [hash length] == 0) + return NO; + + FlycutClipping *newClipping = [[FlycutClipping alloc] initWithImageHash:hash + withImageSize:size + withDisplayLength:[self displayLen] + withAppLocalizedName:appLocalizedName + withAppBundleURL:bundleURL + withTimestamp:timestamp]; + [self addClipping:newClipping]; + [newClipping release]; + return YES; +} + -(bool) removeDuplicates{ return [[[NSUserDefaults standardUserDefaults] valueForKey:@"removeDuplicates"] boolValue]; } @@ -358,13 +374,23 @@ -(NSArray *) previousDisplayStrings:(int)howMany containing:(NSString*)search NSEnumerator *enumerator; FlycutClipping *aClipping; - // If we have a search, do that. Pretty much a mix of the other two paths below, but separated out to avoid extra processing. if (nil != search && search.length > 0) { subArray = [jcList copy]; enumerator = [subArray objectEnumerator]; int index = 0; - while ( aClipping = [enumerator nextObject] ) { // Forward enumerator so we find the most recent N matches - if ([[self clippingContentsAtPosition:index] rangeOfString:search options:NSCaseInsensitiveSearch].location != NSNotFound) { + while ( aClipping = [enumerator nextObject] ) { + BOOL matches = NO; + if ([aClipping isImageClipping]) { + NSString *appName = [aClipping appLocalizedName]; + if (appName && [appName rangeOfString:search options:NSCaseInsensitiveSearch].location != NSNotFound) + matches = YES; + if ([[aClipping displayString] rangeOfString:search options:NSCaseInsensitiveSearch].location != NSNotFound) + matches = YES; + } else { + if ([[self clippingContentsAtPosition:index] rangeOfString:search options:NSCaseInsensitiveSearch].location != NSNotFound) + matches = YES; + } + if (matches) { [returnArray insertObject:[aClipping displayString] atIndex:0]; howMany--; if (0 == howMany) @@ -372,7 +398,7 @@ -(NSArray *) previousDisplayStrings:(int)howMany containing:(NSString*)search } index++; } - return [[returnArray reverseObjectEnumerator] allObjects]; // Reverse the results since the caller expects the most recent to be last. + return [[returnArray reverseObjectEnumerator] allObjects]; } theRange.location = 0; @@ -396,13 +422,23 @@ -(NSArray *) previousIndexes:(int)howMany containing:(NSString*)search // This m NSEnumerator *enumerator; FlycutClipping *aClipping; - // If we have a search, do that. if (nil != search && search.length > 0) { subArray = [jcList copy]; enumerator = [subArray objectEnumerator]; int index = 0; - while ( aClipping = [enumerator nextObject] ) { // Forward enumerator so we find the most recent N matches - if ([[self clippingContentsAtPosition:index] rangeOfString:search options:NSCaseInsensitiveSearch].location != NSNotFound) { + while ( aClipping = [enumerator nextObject] ) { + BOOL matches = NO; + if ([aClipping isImageClipping]) { + NSString *appName = [aClipping appLocalizedName]; + if (appName && [appName rangeOfString:search options:NSCaseInsensitiveSearch].location != NSNotFound) + matches = YES; + if ([[aClipping displayString] rangeOfString:search options:NSCaseInsensitiveSearch].location != NSNotFound) + matches = YES; + } else { + if ([[self clippingContentsAtPosition:index] rangeOfString:search options:NSCaseInsensitiveSearch].location != NSNotFound) + matches = YES; + } + if (matches) { [returnArray addObject:[NSNumber numberWithInt:index]]; howMany--; if (0 == howMany) diff --git a/FlycutOperator.h b/FlycutOperator.h index 362db60..5ce1b7c 100644 --- a/FlycutOperator.h +++ b/FlycutOperator.h @@ -50,6 +50,8 @@ // Basic functionality -(int)indexOfClipping:(NSString*)contents ofType:(NSString*)type fromApp:(NSString *)appName withAppBundleURL:(NSString *)bundleURL; -(bool)addClipping:(NSString*)contents ofType:(NSString*)type fromApp:(NSString *)appName withAppBundleURL:(NSString *)bundleURL target:(id)selectorTarget clippingAddedSelector:(SEL)clippingAddedSelectorclippingAddedSelector; +-(bool)addImageClipping:(NSData *)imageData fromApp:(NSString *)appName withAppBundleURL:(NSString *)bundleURL target:(id)selectorTarget clippingAddedSelector:(SEL)clippingAddedSelector; +-(FlycutClipping *)clippingAtIndex:(int)index; -(int)stackPosition; -(NSString*)getPasteFromStackPosition; -(NSString*)getPasteFromIndex:(int) position; diff --git a/FlycutOperator.m b/FlycutOperator.m index ca5c581..d13459a 100644 --- a/FlycutOperator.m +++ b/FlycutOperator.m @@ -13,7 +13,9 @@ // manipulation of the stores. #import +#import #import "FlycutOperator.h" +#import "FlycutImageStore.h" #ifdef FLYCUT_MAC #import "AppController.h" @@ -240,9 +242,18 @@ - (bool)saveFromStackWithPrefix:(NSString*) prefix - (bool)saveFromStore:(FlycutStore*)store atIndex:(int)index withPrefix:(NSString*) prefix { if ( [store jcListCount] > index ) { - // Get text from clipping store. - NSString *pbFullText = [self clippingStringWithCount:index inStore:store]; - pbFullText = [pbFullText stringByReplacingOccurrencesOfString:@"\r" withString:@"\r\n"]; + FlycutClipping *clipping = [store clippingAtPosition:index]; + BOOL isImage = [clipping isImageClipping]; + + NSString *pbFullText = nil; + NSData *imageData = nil; + if (isImage) { + imageData = [[FlycutImageStore sharedStore] imageDataForHash:[clipping imageHash]]; + if (!imageData) return NO; + } else { + pbFullText = [self clippingStringWithCount:index inStore:store]; + pbFullText = [pbFullText stringByReplacingOccurrencesOfString:@"\r" withString:@"\r\n"]; + } if (!dateFormatterForFilename) { // Date formatters are time-expensive to create, so create once and reuse. @@ -254,9 +265,9 @@ - (bool)saveFromStore:(FlycutStore*)store atIndex:(int)index withPrefix:(NSStrin NSDate *currentDate = [NSDate date]; NSString *dateString = [dateFormatterForFilename stringFromDate:currentDate]; - // Make a file name - NSString *fileName = [NSString stringWithFormat:@"%@%@Clipping %@.txt", - prefix, store == favoritesStore ? @"Favorite " : @"", dateString]; + NSString *fileExt = isImage ? @"tiff" : @"txt"; + NSString *fileName = [NSString stringWithFormat:@"%@%@Clipping %@.%@", + prefix, store == favoritesStore ? @"Favorite " : @"", dateString, fileExt]; // Make a subdirectory, if doing autosave, to avoid a directory with too many files in it as that can make Finder effectively hang on launch if that directory were the desktop. NSString *subdirectoryString = nil; @@ -314,11 +325,14 @@ - (bool)saveFromStore:(FlycutStore*)store atIndex:(int)index withPrefix:(NSStrin baseDirectoryString, fileName]; } - // Save content to the file - [pbFullText writeToFile:fileNameWithPath - atomically:NO - encoding:NSNonLossyASCIIStringEncoding - error:nil]; + if (isImage) { + [imageData writeToFile:fileNameWithPath atomically:YES]; + } else { + [pbFullText writeToFile:fileNameWithPath + atomically:NO + encoding:NSNonLossyASCIIStringEncoding + error:nil]; + } return YES; } return NO; @@ -479,14 +493,80 @@ -(bool)addClipping:(NSString*)contents ofType:(NSString*)type fromApp:(NSString return NO; } +-(bool)addImageClipping:(NSData *)imageData fromApp:(NSString *)appName withAppBundleURL:(NSString *)bundleURL target:(id)selectorTarget clippingAddedSelector:(SEL)clippingAddedSelector +{ + if (!imageData || [imageData length] == 0) + return NO; + + FlycutImageStore *imgStore = [FlycutImageStore sharedStore]; + NSString *hash = [imgStore hashForData:imageData]; + if (!hash) + return NO; + + if ([clippingStore jcListCount] > 0) { + FlycutClipping *topClipping = [clippingStore clippingAtPosition:0]; + if ([topClipping isImageClipping] && [[topClipping imageHash] isEqualToString:hash]) + return NO; + } + + [imgStore saveImageData:imageData forHash:hash]; + + NSSize pixelSize = NSZeroSize; + NSImage *tempImage = [[NSImage alloc] initWithData:imageData]; + if (tempImage) { + NSImageRep *rep = [[tempImage representations] firstObject]; + if (rep) { + pixelSize = NSMakeSize([rep pixelsWide], [rep pixelsHigh]); + } + [tempImage release]; + } + + bool success = [clippingStore addImageClippingWithHash:hash + imageSize:pixelSize + fromAppLocalizedName:appName + fromAppBundleURL:bundleURL + atTimestamp:[[NSDate date] timeIntervalSince1970]]; + stackPosition = 0; + [selectorTarget performSelector:clippingAddedSelector]; + [self actionAfterListModification]; + return success; +} + +-(FlycutClipping *)clippingAtIndex:(int)index +{ + return [clippingStore clippingAtPosition:index]; +} + +-(BOOL)isImageHashReferencedElsewhere:(NSString *)hash excludingStore:(FlycutStore *)excludeStore atIndex:(int)excludeIndex +{ + FlycutStore *stores[] = { clippingStore, favoritesStore }; + for (int s = 0; s < 2; s++) { + FlycutStore *store = stores[s]; + if (!store) continue; + for (int i = 0; i < [store jcListCount]; i++) { + if (store == excludeStore && i == excludeIndex) + continue; + FlycutClipping *c = [store clippingAtPosition:i]; + if ([c isImageClipping] && [[c imageHash] isEqualToString:hash]) + return YES; + } + } + return NO; +} + - (void)willDeleteClippingFromStore:(id)store AtIndex:(int)index { - if ( (!inhibitAutosaveClippings) // Avoid saving things that the user explicitly deletes. + FlycutClipping *clipping = [(FlycutStore *)store clippingAtPosition:index]; + if ([clipping isImageClipping]) { + if (![self isImageHashReferencedElsewhere:[clipping imageHash] excludingStore:(FlycutStore *)store atIndex:index]) { + [[FlycutImageStore sharedStore] deleteImageForHash:[clipping imageHash]]; + } + } + + if ( (!inhibitAutosaveClippings) && ( store == favoritesStore ? [[[NSUserDefaults standardUserDefaults] valueForKey:@"saveForgottenFavorites"] boolValue] : [[[NSUserDefaults standardUserDefaults] valueForKey:@"saveForgottenClippings"] boolValue] ) ) { - // clipping is being removed, so save it before it gets lost. - // Set to last item, save, and restore position. [self saveFromStore:store atIndex:index withPrefix:@"Autosave "]; } } @@ -981,16 +1061,30 @@ -(bool) loadEngineFrom:(NSDictionary*)loadDict key:(NSString*)listKey into:(Flyc { NSArray *savedJCList = [loadDict objectForKey:listKey]; if ( [savedJCList isKindOfClass:[NSArray class]] ) { - // There's probably a nicer way to prevent the range from going out of bounds, but this works. int rangeCap = [savedJCList count] < [store rememberNum] ? [savedJCList count] : [store rememberNum]; NSRange loadRange = NSMakeRange(0, rangeCap); NSArray *toBeRestoredClips = [[[savedJCList subarrayWithRange:loadRange] reverseObjectEnumerator] allObjects]; - for( NSDictionary *aSavedClipping in toBeRestoredClips) - [store addClipping:[aSavedClipping objectForKey:@"Contents"] - ofType:[aSavedClipping objectForKey:@"Type"] - fromAppLocalizedName:[aSavedClipping objectForKey:@"AppLocalizedName"] - fromAppBundleURL:[aSavedClipping objectForKey:@"AppBundleURL"] - atTimestamp:[[aSavedClipping objectForKey:@"Timestamp"] integerValue]]; + for( NSDictionary *aSavedClipping in toBeRestoredClips) { + NSString *imgHash = [aSavedClipping objectForKey:@"ImageHash"]; + if (imgHash && [imgHash length] > 0) { + if ([[FlycutImageStore sharedStore] hasImageForHash:imgHash]) { + NSSize imgSize = NSMakeSize( + [[aSavedClipping objectForKey:@"ImageWidth"] doubleValue], + [[aSavedClipping objectForKey:@"ImageHeight"] doubleValue]); + [store addImageClippingWithHash:imgHash + imageSize:imgSize + fromAppLocalizedName:[aSavedClipping objectForKey:@"AppLocalizedName"] + fromAppBundleURL:[aSavedClipping objectForKey:@"AppBundleURL"] + atTimestamp:[[aSavedClipping objectForKey:@"Timestamp"] integerValue]]; + } + } else { + [store addClipping:[aSavedClipping objectForKey:@"Contents"] + ofType:[aSavedClipping objectForKey:@"Type"] + fromAppLocalizedName:[aSavedClipping objectForKey:@"AppLocalizedName"] + fromAppBundleURL:[aSavedClipping objectForKey:@"AppBundleURL"] + atTimestamp:[[aSavedClipping objectForKey:@"Timestamp"] integerValue]]; + } + } return YES; } else DLog(@"Not array"); return NO; @@ -1059,6 +1153,12 @@ - (void)saveStore:(FlycutStore *)store toKey:(NSString *)key onDict:(NSMutableDi [clipping type], @"Type", [NSNumber numberWithInt:i], @"Position",nil]; + if ([clipping isImageClipping]) { + [dict setObject:[clipping imageHash] forKey:@"ImageHash"]; + [dict setObject:[NSNumber numberWithDouble:[clipping imageSize].width] forKey:@"ImageWidth"]; + [dict setObject:[NSNumber numberWithDouble:[clipping imageSize].height] forKey:@"ImageHeight"]; + } + NSString *val = [clipping appLocalizedName]; if ( nil != val ) [dict setObject:val forKey:@"AppLocalizedName"]; @@ -1085,7 +1185,7 @@ -(void) saveEngine { NSMutableDictionary *saveDict; saveDict = [NSMutableDictionary dictionaryWithCapacity:3]; - [saveDict setObject:@"0.7" forKey:@"version"]; + [saveDict setObject:@"0.8" forKey:@"version"]; [saveDict setObject:[NSNumber numberWithInt:[[NSUserDefaults standardUserDefaults] integerForKey:@"rememberNum"]] forKey:@"rememberNum"]; [saveDict setObject:[NSNumber numberWithInt:[[NSUserDefaults standardUserDefaults] integerForKey:@"favoritesRememberNum"]] diff --git a/UI/BezelWindow.h b/UI/BezelWindow.h index 0a6f9a6..8c8eecb 100755 --- a/UI/BezelWindow.h +++ b/UI/BezelWindow.h @@ -40,6 +40,8 @@ RoundRecTextField *textField; RoundRecTextField *charField; NSImageView *iconView; + NSImageView *imagePreview; + BOOL isShowingImage; id delegate; Boolean color; } @@ -63,6 +65,8 @@ - (void)setSource:(NSString *)newSource; - (void)setDate:(NSString *)newDate; - (void)setSourceIcon:(NSImage *)newSourceIcon; +- (void)setImage:(NSImage *)image; +- (void)clearImage; - (id)delegate; - (void)setDelegate:(id)newDelegate; diff --git a/UI/BezelWindow.m b/UI/BezelWindow.m index 5696100..92d000a 100755 --- a/UI/BezelWindow.m +++ b/UI/BezelWindow.m @@ -195,7 +195,15 @@ - (id)initWithContentRect:(NSRect)contentRect [charField.heightAnchor constraintEqualToConstant:charFrame.size.height], ]]; - [self setInitialFirstResponder:textField]; + imagePreview = [[NSImageView alloc] initWithFrame:textFrame]; + [imagePreview setImageScaling:NSImageScaleProportionallyUpOrDown]; + [imagePreview setImageAlignment:NSImageAlignCenter]; + [imagePreview setImageFrameStyle:NSImageFrameNone]; + [imagePreview setHidden:YES]; + [[self contentView] addSubview:imagePreview]; + isShowingImage = NO; + + [self setInitialFirstResponder:textField]; return self; } return nil; @@ -210,10 +218,10 @@ - (void) update { if (nil == sourceText || 0 == sourceText.length) showSourceField = false; - // Defer frame updates to avoid layout recursion dispatch_async(dispatch_get_main_queue(), ^{ NSRect textFrame = [self textFrame]; [textField setFrame:textFrame]; + [imagePreview setFrame:textFrame]; NSRect charFrame = [self charFrame]; [charField setFrame:charFrame]; }); @@ -313,8 +321,7 @@ - (void)setCharString:(NSString *)newChar - (void)setText:(NSString *)newText { - // The Bezel gets slow when newText is huge. Probably the retain. - // Since we can't see that much of it anyway, trim to 2000 characters. + [self clearImage]; if ([newText length] > 2000) newText = [newText substringToIndex:2000]; [newText retain]; @@ -323,6 +330,28 @@ - (void)setText:(NSString *)newText [textField.textField setStringValue:bezelText]; } +- (void)setImage:(NSImage *)image +{ + if (!image) { + [self clearImage]; + return; + } + [textField setHidden:YES]; + [imagePreview setImage:image]; + [imagePreview setHidden:NO]; + isShowingImage = YES; +} + +- (void)clearImage +{ + if (isShowingImage) { + [imagePreview setHidden:YES]; + [imagePreview setImage:nil]; + [textField setHidden:NO]; + isShowingImage = NO; + } +} + - (void)setSourceIcon:(NSImage *)newSourceIcon { if (!showSourceField) @@ -408,6 +437,7 @@ - (void)dealloc [textField release]; [charField release]; [iconView release]; + [imagePreview release]; [super dealloc]; } From 5564404d202c188dd5b7f4c5c228afea588c8bb7 Mon Sep 17 00:00:00 2001 From: Ankush Vangari Date: Tue, 16 Jun 2026 14:16:47 -0700 Subject: [PATCH 2/4] Preserve animated GIFs through capture and paste Chromium browsers flatten animated GIFs to a still PNG on "Copy Image" but embed the source GIF URL in the clipboard HTML. Detect that URL and download the original animated GIF instead of capturing the still. - Format-aware image store: files keyed by content hash + real extension (.gif/.png/.tiff); a resolver finds whatever extension a hash was stored under, so existing .tiff clippings keep working. - Carry the pasteboard UTI (e.g. com.compuserve.gif) on image clippings through capture, persistence (reusing the saved "Type"), and paste-back. - pollPB prefers a real GIF on the pasteboard; otherwise it reads the URL from the clipboard HTML and downloads the GIF (https only, .gif path, size cap, 5s timeout, animated-only; skips on failure). - Paste GIF clippings back as a file-URL only, so apps like Slack/Discord upload the actual animated file instead of flattening raw image data to a single frame. - Bezel preview animates GIFs (imagePreview.animates = YES). - New "Download animated GIFs from web" preference (default on). Co-Authored-By: Claude Opus 4.8 (1M context) --- AppController.h | 3 +- AppController.m | 177 +++++++++++++++++++++++++++----- FlycutEngine/FlycutClipping.h | 2 +- FlycutEngine/FlycutClipping.m | 11 +- FlycutEngine/FlycutImageStore.h | 6 ++ FlycutEngine/FlycutImageStore.m | 71 +++++++++---- FlycutEngine/FlycutStore.h | 2 +- FlycutEngine/FlycutStore.m | 3 +- FlycutOperator.h | 2 +- FlycutOperator.m | 11 +- UI/BezelWindow.m | 1 + 11 files changed, 232 insertions(+), 57 deletions(-) diff --git a/AppController.h b/AppController.h index 4d85a7e..85f1b4c 100755 --- a/AppController.h +++ b/AppController.h @@ -21,6 +21,7 @@ #import "SGHotKey.h" @class SGHotKey; +@class FlycutClipping; @interface AppController : NSResponder { BezelWindow *bezel; @@ -91,7 +92,7 @@ // Basic functionality -(void) pollPB:(NSTimer *)timer; -(void) addClipToPasteboard:(NSString*)pbFullText; --(void) addImageClipToPasteboard:(NSString *)imageHash; +-(void) addImageClipToPasteboard:(FlycutClipping *)clipping; -(void) setPBBlockCount:(NSNumber *)newPBBlockCount; -(void) hideApp; -(void) fakeCommandV; diff --git a/AppController.m b/AppController.m index a12a836..fd5e566 100755 --- a/AppController.m +++ b/AppController.m @@ -54,6 +54,14 @@ - (BOOL)performKeyEquivalent:(NSEvent *)theEvent { @end +@interface AppController () +// Returns the source GIF URL embedded in the clipboard HTML (e.g. Chrome "Copy Image"), +// but only for https URLs that point at a .gif. nil otherwise. +-(NSURL *)animatedGifURLFromPasteboard; +// Asynchronously downloads url and, if it is an animated GIF, adds it as a clipping. +-(void)downloadAndCaptureGIFFromURL:(NSURL *)url fromApp:(NSString *)appName withAppBundleURL:(NSString *)bundleURL; +@end + @implementation AppController @@ -99,6 +107,8 @@ - (id)init @"captureImages", [NSNumber numberWithInt:10240], @"maxImageSizeKB", + [NSNumber numberWithBool:YES], + @"downloadAnimatedGIFs", nil]]; /* For testing, the ability to force initial values of the sync settings: @@ -777,7 +787,14 @@ -(void) buildAppearancesPreferencePanel action:nil]; [appearancePanel addSubview:row]; nextYMax = row.frame.origin.y; - + + row = [self preferencePanelCheckboxRowForText:@"Download animated GIFs from web" + frameMaxY:nextYMax + binding:@"downloadAnimatedGIFs" + action:nil]; + [appearancePanel addSubview:row]; + nextYMax = row.frame.origin.y; + // Add Accessibility Check button NSRect panelFrame = [appearancePanel frame]; int height = 40; @@ -897,7 +914,7 @@ - (void)pasteFromStack FlycutClipping *clipping = [flycutOperator clippingAtStackPosition]; if (clipping) { if ([clipping isImageClipping]) { - [self addImageClipToPasteboard:[clipping imageHash]]; + [self addImageClipToPasteboard:clipping]; } else { NSString *content = [flycutOperator getPasteFromStackPosition]; if (content) { @@ -932,7 +949,7 @@ - (void)pasteIndexAndUpdate:(int) position { FlycutClipping *clipping = [flycutOperator clippingAtIndex:position]; if (clipping && [clipping isImageClipping]) { - [self addImageClipToPasteboard:[clipping imageHash]]; + [self addImageClipToPasteboard:clipping]; if ([[NSUserDefaults standardUserDefaults] boolForKey:@"pasteMovesToTop"]) { [flycutOperator getPasteFromIndex:position]; } @@ -1091,7 +1108,7 @@ -(BOOL)control:(NSControl *)control textView:(NSTextView *)fieldEditor doCommand -(void)pollPB:(NSTimer *)timer { NSString *textType = [jcPasteboard availableTypeFromArray:@[NSPasteboardTypeString]]; - NSString *imageType = [jcPasteboard availableTypeFromArray:@[NSPasteboardTypeTIFF, NSPasteboardTypePNG]]; + NSString *imageType = [jcPasteboard availableTypeFromArray:@[@"com.compuserve.gif", NSPasteboardTypeTIFF, NSPasteboardTypePNG]]; if ( [pbCount intValue] != [jcPasteboard changeCount] && ![flycutOperator storeDisabled] ) { [pbCount release]; @@ -1129,26 +1146,40 @@ -(void)pollPB:(NSTimer *)timer if ( [flycutOperator shouldSkip:@"" ofType:imageType fromAvailableTypes:[jcPasteboard types]] ) { DLog(@"Image: skipped due to pasteboard type filter"); } else { - dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0); - dispatch_async(queue, ^{ - NSData *imageData = [[jcPasteboard dataForType:NSPasteboardTypeTIFF] copy]; - if (imageData && [imageData length] > 0) { - int maxSizeKB = (int)[[NSUserDefaults standardUserDefaults] integerForKey:@"maxImageSizeKB"]; - if (maxSizeKB <= 0) maxSizeKB = 10240; - if ([imageData length] <= (NSUInteger)maxSizeKB * 1024) { - dispatch_async(dispatch_get_main_queue(), ^{ - if ( ! [pbCount isEqualTo:pbBlockCount] ) { - [flycutOperator addImageClipping:imageData fromApp:[currRunningApp localizedName] withAppBundleURL:currRunningApp.bundleURL.path target:self clippingAddedSelector:@selector(updateMenu)]; - } + // When the clipboard carries only a flattened still but embeds the source + // GIF's URL (e.g. Chrome "Copy Image" on an animated GIF), fetch the real + // animated file instead of capturing the still. + BOOL haveDirectGIF = [imageType isEqualToString:@"com.compuserve.gif"]; + NSURL *gifURL = nil; + if ( !haveDirectGIF && [[NSUserDefaults standardUserDefaults] boolForKey:@"downloadAnimatedGIFs"] ) + gifURL = [self animatedGifURLFromPasteboard]; + + if ( gifURL != nil ) { + // Skip the still entirely; the async download adds the animated GIF on success. + [self downloadAndCaptureGIFFromURL:gifURL fromApp:[currRunningApp localizedName] withAppBundleURL:currRunningApp.bundleURL.path]; + } else { + NSString *captureType = imageType; + dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0); + dispatch_async(queue, ^{ + NSData *imageData = [[jcPasteboard dataForType:captureType] copy]; + if (imageData && [imageData length] > 0) { + int maxSizeKB = (int)[[NSUserDefaults standardUserDefaults] integerForKey:@"maxImageSizeKB"]; + if (maxSizeKB <= 0) maxSizeKB = 10240; + if ([imageData length] <= (NSUInteger)maxSizeKB * 1024) { + dispatch_async(dispatch_get_main_queue(), ^{ + if ( ! [pbCount isEqualTo:pbBlockCount] ) { + [flycutOperator addImageClipping:imageData fromApp:[currRunningApp localizedName] withAppBundleURL:currRunningApp.bundleURL.path imageType:captureType target:self clippingAddedSelector:@selector(updateMenu)]; + } + [imageData release]; + }); + } else { [imageData release]; - }); + } } else { [imageData release]; } - } else { - [imageData release]; - } - }); + }); + } } } } @@ -1606,16 +1637,112 @@ -(void)addClipToPasteboard:(NSString*)pbFullText [self setPBBlockCount:[NSNumber numberWithInt:[jcPasteboard changeCount]]]; } --(void)addImageClipToPasteboard:(NSString *)imageHash +-(void)addImageClipToPasteboard:(FlycutClipping *)clipping { - NSData *imageData = [[FlycutImageStore sharedStore] imageDataForHash:imageHash]; + NSString *type = [clipping type]; + if (!type || [type length] == 0) + type = NSPasteboardTypeTIFF; + + // Animated GIFs must be offered as a FILE reference ONLY. Apps like Slack/Discord + // upload the actual .gif file (preserving animation); if raw image data is also on + // the pasteboard they grab that instead and flatten it to a single frame. Pointing + // at the persistent store file is safe — it lives as long as the clipping does. + if ([type isEqualToString:@"com.compuserve.gif"]) { + NSString *path = [[FlycutImageStore sharedStore] imageFilePathForHash:[clipping imageHash]]; + if (path) { + [jcPasteboard clearContents]; + [jcPasteboard writeObjects:@[[NSURL fileURLWithPath:path]]]; + [self setPBBlockCount:[NSNumber numberWithInt:[jcPasteboard changeCount]]]; + return; + } + } + + NSData *imageData = [[FlycutImageStore sharedStore] imageDataForHash:[clipping imageHash]]; if (imageData) { - [jcPasteboard declareTypes:@[NSPasteboardTypeTIFF] owner:nil]; - [jcPasteboard setData:imageData forType:NSPasteboardTypeTIFF]; + [jcPasteboard declareTypes:@[type] owner:nil]; + [jcPasteboard setData:imageData forType:type]; [self setPBBlockCount:[NSNumber numberWithInt:[jcPasteboard changeCount]]]; } } +-(NSURL *)animatedGifURLFromPasteboard +{ + NSData *htmlData = [jcPasteboard dataForType:@"public.html"]; + if (htmlData == nil || [htmlData length] == 0) + return nil; + + NSString *html = [[[NSString alloc] initWithData:htmlData encoding:NSUTF8StringEncoding] autorelease]; + if (html == nil || [html length] == 0) + return nil; + + // Pull the first URL out of the clipboard HTML. + NSRegularExpression *re = [NSRegularExpression regularExpressionWithPattern:@"]+src=[\"']([^\"']+)[\"']" + options:NSRegularExpressionCaseInsensitive + error:nil]; + if (re == nil) + return nil; + NSTextCheckingResult *match = [re firstMatchInString:html options:0 range:NSMakeRange(0, [html length])]; + if (match == nil || [match numberOfRanges] < 2) + return nil; + + NSString *src = [html substringWithRange:[match rangeAtIndex:1]]; + NSURL *url = [NSURL URLWithString:src]; + if (url == nil) + return nil; + + // Only fetch over HTTPS, and only when the URL actually points at a GIF. + if (![[[url scheme] lowercaseString] isEqualToString:@"https"]) + return nil; + if (![[[url path] lowercaseString] hasSuffix:@".gif"]) + return nil; + + return url; +} + +-(void)downloadAndCaptureGIFFromURL:(NSURL *)url fromApp:(NSString *)appName withAppBundleURL:(NSString *)bundleURL +{ + int maxSizeKB = (int)[[NSUserDefaults standardUserDefaults] integerForKey:@"maxImageSizeKB"]; + if (maxSizeKB <= 0) maxSizeKB = 10240; + NSUInteger maxBytes = (NSUInteger)maxSizeKB * 1024; + + NSURLSessionConfiguration *config = [NSURLSessionConfiguration ephemeralSessionConfiguration]; + config.timeoutIntervalForRequest = 5.0; + config.timeoutIntervalForResource = 10.0; + NSURLSession *session = [NSURLSession sessionWithConfiguration:config]; + + NSURLSessionDataTask *task = [session dataTaskWithURL:url completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { + // On any failure (offline, timeout, non-200, too big, not an animated GIF) do + // nothing — per design we skip the entry rather than fall back to a still. + if (error != nil || data == nil || [data length] == 0) + return; + if ([response isKindOfClass:[NSHTTPURLResponse class]] && [(NSHTTPURLResponse *)response statusCode] != 200) + return; + if ([data length] > maxBytes || [data length] < 6) + return; + + // Verify GIF magic bytes ("GIF87a"/"GIF89a")... + const unsigned char *bytes = (const unsigned char *)[data bytes]; + if (!(bytes[0] == 'G' && bytes[1] == 'I' && bytes[2] == 'F')) + return; + + // ...and that it is actually animated (more than one frame). + NSBitmapImageRep *rep = [NSBitmapImageRep imageRepWithData:data]; + NSNumber *frames = rep ? [rep valueForProperty:NSImageFrameCount] : nil; + if (frames == nil || [frames integerValue] <= 1) + return; + + NSData *gifData = [data retain]; + dispatch_async(dispatch_get_main_queue(), ^{ + if ( ! [pbCount isEqualTo:pbBlockCount] ) { + [flycutOperator addImageClipping:gifData fromApp:appName withAppBundleURL:bundleURL imageType:@"com.compuserve.gif" target:self clippingAddedSelector:@selector(updateMenu)]; + } + [gifData release]; + }); + }]; + [task resume]; + [session finishTasksAndInvalidate]; +} + -(void) stackDown { NSLog(@"stackDown: current position=%d, total count=%d", [flycutOperator stackPosition], [flycutOperator jcListCount]); @@ -1973,7 +2100,7 @@ - (IBAction)searchWindowItemSelected:(id)sender FlycutClipping *clipping = [flycutOperator clippingAtIndex:position]; if (clipping && [clipping isImageClipping]) { - [self addImageClipToPasteboard:[clipping imageHash]]; + [self addImageClipToPasteboard:clipping]; if ([[NSUserDefaults standardUserDefaults] boolForKey:@"pasteMovesToTop"]) { [flycutOperator getPasteFromIndex:position]; } diff --git a/FlycutEngine/FlycutClipping.h b/FlycutEngine/FlycutClipping.h index 2fb98cf..d12ad0a 100755 --- a/FlycutEngine/FlycutClipping.h +++ b/FlycutEngine/FlycutClipping.h @@ -25,7 +25,7 @@ } -(id) initWithContents:(NSString *)contents withType:(NSString *)type withDisplayLength:(int)displayLength withAppLocalizedName:(NSString *)localizedName withAppBundleURL:(NSString *)bundleURL withTimestamp:(NSInteger)timestamp; --(id) initWithImageHash:(NSString *)hash withImageSize:(NSSize)size withDisplayLength:(int)displayLength withAppLocalizedName:(NSString *)localizedName withAppBundleURL:(NSString *)bundleURL withTimestamp:(NSInteger)timestamp; +-(id) initWithImageHash:(NSString *)hash withImageSize:(NSSize)size withImageType:(NSString *)imageType withDisplayLength:(int)displayLength withAppLocalizedName:(NSString *)localizedName withAppBundleURL:(NSString *)bundleURL withTimestamp:(NSInteger)timestamp; /* -(id) initWithCoder:(NSCoder *)coder; -(void) decodeWithCoder:(NSCoder *)coder; */ -(NSString *) description; diff --git a/FlycutEngine/FlycutClipping.m b/FlycutEngine/FlycutClipping.m index 2e1ebb9..250f364 100755 --- a/FlycutEngine/FlycutClipping.m +++ b/FlycutEngine/FlycutClipping.m @@ -45,12 +45,12 @@ -(id) initWithContents:(NSString *)contents withType:(NSString *)type withDispla return self; } --(id) initWithImageHash:(NSString *)hash withImageSize:(NSSize)size withDisplayLength:(int)displayLength withAppLocalizedName:(NSString *)localizedName withAppBundleURL:(NSString*)bundleURL withTimestamp:(NSInteger)timestamp +-(id) initWithImageHash:(NSString *)hash withImageSize:(NSSize)size withImageType:(NSString *)imageType withDisplayLength:(int)displayLength withAppLocalizedName:(NSString *)localizedName withAppBundleURL:(NSString*)bundleURL withTimestamp:(NSInteger)timestamp { [super init]; clipContents = [@"" retain]; clipDisplayString = [[[NSString alloc] init] retain]; - clipType = [NSPasteboardTypeTIFF retain]; + clipType = [(imageType != nil ? imageType : NSPasteboardTypeTIFF) retain]; clipDisplayLength = displayLength > 0 ? displayLength : 40; [self setImageHash:hash]; @@ -161,12 +161,13 @@ -(void) resetDisplayString [clipDisplayString release]; if (imageHash != nil) { + NSString *label = [clipType isEqualToString:@"com.compuserve.gif"] ? @"GIF" : @"Image"; NSString *newDisplayString; if (imageSize.width > 0 && imageSize.height > 0) { - newDisplayString = [NSString stringWithFormat:@"[Image %dx%d]", - (int)imageSize.width, (int)imageSize.height]; + newDisplayString = [NSString stringWithFormat:@"[%@ %dx%d]", + label, (int)imageSize.width, (int)imageSize.height]; } else { - newDisplayString = @"[Image]"; + newDisplayString = [NSString stringWithFormat:@"[%@]", label]; } [newDisplayString retain]; clipDisplayString = newDisplayString; diff --git a/FlycutEngine/FlycutImageStore.h b/FlycutEngine/FlycutImageStore.h index 829a0ac..8c61a00 100644 --- a/FlycutEngine/FlycutImageStore.h +++ b/FlycutEngine/FlycutImageStore.h @@ -14,9 +14,15 @@ +(FlycutImageStore *)sharedStore; +// Maps a pasteboard UTI (e.g. com.compuserve.gif) to the on-disk file extension. ++(NSString *)fileExtensionForType:(NSString *)uti; + -(NSString *)hashForData:(NSData *)data; -(BOOL)saveImageData:(NSData *)data forHash:(NSString *)hash; +-(BOOL)saveImageData:(NSData *)data forHash:(NSString *)hash extension:(NSString *)ext; -(NSData *)imageDataForHash:(NSString *)hash; +// Absolute path to the stored image file for a hash (whatever extension), or nil. +-(NSString *)imageFilePathForHash:(NSString *)hash; -(void)deleteImageForHash:(NSString *)hash; -(BOOL)hasImageForHash:(NSString *)hash; -(NSString *)imagesDirectoryPath; diff --git a/FlycutEngine/FlycutImageStore.m b/FlycutEngine/FlycutImageStore.m index 683be39..c9a256c 100644 --- a/FlycutEngine/FlycutImageStore.m +++ b/FlycutEngine/FlycutImageStore.m @@ -19,6 +19,17 @@ +(FlycutImageStore *)sharedStore return sharedInstance; } ++(NSString *)fileExtensionForType:(NSString *)uti +{ + if ([uti isEqualToString:@"com.compuserve.gif"]) + return @"gif"; + if ([uti isEqualToString:@"public.png"]) + return @"png"; + if ([uti isEqualToString:@"public.jpeg"]) + return @"jpg"; + return @"tiff"; +} + -(id)init { self = [super init]; @@ -51,51 +62,72 @@ -(NSString *)hashForData:(NSData *)data return hashString; } --(NSString *)filePathForHash:(NSString *)hash +-(NSString *)filePathForHash:(NSString *)hash extension:(NSString *)ext { + if (!ext || [ext length] == 0) + ext = @"tiff"; return [imagesDirectoryPath stringByAppendingPathComponent: - [NSString stringWithFormat:@"%@.tiff", hash]]; + [NSString stringWithFormat:@"%@.%@", hash, ext]]; +} + +// Resolves a hash to whatever extension it was actually stored under (e.g. .gif, +// .png, or legacy .tiff). Returns nil if no matching file exists. +-(NSString *)resolvedPathForHash:(NSString *)hash +{ + if (!hash) + return nil; + + NSFileManager *fm = [NSFileManager defaultManager]; + NSString *prefix = [hash stringByAppendingString:@"."]; + for (NSString *file in [fm contentsOfDirectoryAtPath:imagesDirectoryPath error:nil]) { + if ([file hasPrefix:prefix]) + return [imagesDirectoryPath stringByAppendingPathComponent:file]; + } + return nil; } -(BOOL)saveImageData:(NSData *)data forHash:(NSString *)hash +{ + return [self saveImageData:data forHash:hash extension:@"tiff"]; +} + +-(BOOL)saveImageData:(NSData *)data forHash:(NSString *)hash extension:(NSString *)ext { if (!data || !hash) return NO; - NSString *filePath = [self filePathForHash:hash]; - if ([[NSFileManager defaultManager] fileExistsAtPath:filePath]) + // Content-addressed: if any representation of this hash is already stored, keep it. + if ([self resolvedPathForHash:hash]) return YES; + NSString *filePath = [self filePathForHash:hash extension:ext]; return [data writeToFile:filePath atomically:YES]; } -(NSData *)imageDataForHash:(NSString *)hash { - if (!hash) - return nil; - - NSString *filePath = [self filePathForHash:hash]; - if (![[NSFileManager defaultManager] fileExistsAtPath:filePath]) + NSString *filePath = [self resolvedPathForHash:hash]; + if (!filePath) return nil; return [NSData dataWithContentsOfFile:filePath]; } --(void)deleteImageForHash:(NSString *)hash +-(NSString *)imageFilePathForHash:(NSString *)hash { - if (!hash) - return; + return [self resolvedPathForHash:hash]; +} - NSString *filePath = [self filePathForHash:hash]; - [[NSFileManager defaultManager] removeItemAtPath:filePath error:nil]; +-(void)deleteImageForHash:(NSString *)hash +{ + NSString *filePath = [self resolvedPathForHash:hash]; + if (filePath) + [[NSFileManager defaultManager] removeItemAtPath:filePath error:nil]; } -(BOOL)hasImageForHash:(NSString *)hash { - if (!hash) - return NO; - - return [[NSFileManager defaultManager] fileExistsAtPath:[self filePathForHash:hash]]; + return [self resolvedPathForHash:hash] != nil; } -(NSString *)imagesDirectoryPath @@ -108,7 +140,8 @@ -(NSArray *)allStoredHashes NSArray *files = [[NSFileManager defaultManager] contentsOfDirectoryAtPath:imagesDirectoryPath error:nil]; NSMutableArray *hashes = [NSMutableArray arrayWithCapacity:[files count]]; for (NSString *file in files) { - if ([file hasSuffix:@".tiff"]) { + // Any stored image representation (.tiff/.gif/.png/.jpg) maps back to its hash. + if ([[file pathExtension] length] > 0 && ![file hasPrefix:@"."]) { [hashes addObject:[file stringByDeletingPathExtension]]; } } diff --git a/FlycutEngine/FlycutStore.h b/FlycutEngine/FlycutStore.h index 28dd8d2..d99a61c 100755 --- a/FlycutEngine/FlycutStore.h +++ b/FlycutEngine/FlycutStore.h @@ -87,7 +87,7 @@ // Add a clipping -(bool) addClipping:(NSString *)clipping ofType:(NSString *)type fromAppLocalizedName:(NSString *)appLocalizedName fromAppBundleURL:(NSString *)bundleURL atTimestamp:(NSInteger) timestamp; --(bool) addImageClippingWithHash:(NSString *)hash imageSize:(NSSize)size fromAppLocalizedName:(NSString *)appLocalizedName fromAppBundleURL:(NSString *)bundleURL atTimestamp:(NSInteger)timestamp; +-(bool) addImageClippingWithHash:(NSString *)hash imageSize:(NSSize)size imageType:(NSString *)imageType fromAppLocalizedName:(NSString *)appLocalizedName fromAppBundleURL:(NSString *)bundleURL atTimestamp:(NSInteger)timestamp; -(void) addClipping:(FlycutClipping*) clipping; -(void) insertClipping:(FlycutClipping*) clipping atIndex:(int) index; diff --git a/FlycutEngine/FlycutStore.m b/FlycutEngine/FlycutStore.m index 6c4e547..a6c5dd4 100755 --- a/FlycutEngine/FlycutStore.m +++ b/FlycutEngine/FlycutStore.m @@ -95,13 +95,14 @@ -(bool) addClipping:(NSString *)clipping ofType:(NSString *)type fromAppLocalize return YES; } --(bool) addImageClippingWithHash:(NSString *)hash imageSize:(NSSize)size fromAppLocalizedName:(NSString *)appLocalizedName fromAppBundleURL:(NSString *)bundleURL atTimestamp:(NSInteger)timestamp +-(bool) addImageClippingWithHash:(NSString *)hash imageSize:(NSSize)size imageType:(NSString *)imageType fromAppLocalizedName:(NSString *)appLocalizedName fromAppBundleURL:(NSString *)bundleURL atTimestamp:(NSInteger)timestamp { if (!hash || [hash length] == 0) return NO; FlycutClipping *newClipping = [[FlycutClipping alloc] initWithImageHash:hash withImageSize:size + withImageType:imageType withDisplayLength:[self displayLen] withAppLocalizedName:appLocalizedName withAppBundleURL:bundleURL diff --git a/FlycutOperator.h b/FlycutOperator.h index 5ce1b7c..031593c 100644 --- a/FlycutOperator.h +++ b/FlycutOperator.h @@ -50,7 +50,7 @@ // Basic functionality -(int)indexOfClipping:(NSString*)contents ofType:(NSString*)type fromApp:(NSString *)appName withAppBundleURL:(NSString *)bundleURL; -(bool)addClipping:(NSString*)contents ofType:(NSString*)type fromApp:(NSString *)appName withAppBundleURL:(NSString *)bundleURL target:(id)selectorTarget clippingAddedSelector:(SEL)clippingAddedSelectorclippingAddedSelector; --(bool)addImageClipping:(NSData *)imageData fromApp:(NSString *)appName withAppBundleURL:(NSString *)bundleURL target:(id)selectorTarget clippingAddedSelector:(SEL)clippingAddedSelector; +-(bool)addImageClipping:(NSData *)imageData fromApp:(NSString *)appName withAppBundleURL:(NSString *)bundleURL imageType:(NSString *)imageType target:(id)selectorTarget clippingAddedSelector:(SEL)clippingAddedSelector; -(FlycutClipping *)clippingAtIndex:(int)index; -(int)stackPosition; -(NSString*)getPasteFromStackPosition; diff --git a/FlycutOperator.m b/FlycutOperator.m index d13459a..589bd04 100644 --- a/FlycutOperator.m +++ b/FlycutOperator.m @@ -265,7 +265,7 @@ - (bool)saveFromStore:(FlycutStore*)store atIndex:(int)index withPrefix:(NSStrin NSDate *currentDate = [NSDate date]; NSString *dateString = [dateFormatterForFilename stringFromDate:currentDate]; - NSString *fileExt = isImage ? @"tiff" : @"txt"; + NSString *fileExt = isImage ? [FlycutImageStore fileExtensionForType:[clipping type]] : @"txt"; NSString *fileName = [NSString stringWithFormat:@"%@%@Clipping %@.%@", prefix, store == favoritesStore ? @"Favorite " : @"", dateString, fileExt]; @@ -493,11 +493,14 @@ -(bool)addClipping:(NSString*)contents ofType:(NSString*)type fromApp:(NSString return NO; } --(bool)addImageClipping:(NSData *)imageData fromApp:(NSString *)appName withAppBundleURL:(NSString *)bundleURL target:(id)selectorTarget clippingAddedSelector:(SEL)clippingAddedSelector +-(bool)addImageClipping:(NSData *)imageData fromApp:(NSString *)appName withAppBundleURL:(NSString *)bundleURL imageType:(NSString *)imageType target:(id)selectorTarget clippingAddedSelector:(SEL)clippingAddedSelector { if (!imageData || [imageData length] == 0) return NO; + if (!imageType) + imageType = NSPasteboardTypeTIFF; + FlycutImageStore *imgStore = [FlycutImageStore sharedStore]; NSString *hash = [imgStore hashForData:imageData]; if (!hash) @@ -509,7 +512,7 @@ -(bool)addImageClipping:(NSData *)imageData fromApp:(NSString *)appName withAppB return NO; } - [imgStore saveImageData:imageData forHash:hash]; + [imgStore saveImageData:imageData forHash:hash extension:[FlycutImageStore fileExtensionForType:imageType]]; NSSize pixelSize = NSZeroSize; NSImage *tempImage = [[NSImage alloc] initWithData:imageData]; @@ -523,6 +526,7 @@ -(bool)addImageClipping:(NSData *)imageData fromApp:(NSString *)appName withAppB bool success = [clippingStore addImageClippingWithHash:hash imageSize:pixelSize + imageType:imageType fromAppLocalizedName:appName fromAppBundleURL:bundleURL atTimestamp:[[NSDate date] timeIntervalSince1970]]; @@ -1073,6 +1077,7 @@ -(bool) loadEngineFrom:(NSDictionary*)loadDict key:(NSString*)listKey into:(Flyc [[aSavedClipping objectForKey:@"ImageHeight"] doubleValue]); [store addImageClippingWithHash:imgHash imageSize:imgSize + imageType:[aSavedClipping objectForKey:@"Type"] fromAppLocalizedName:[aSavedClipping objectForKey:@"AppLocalizedName"] fromAppBundleURL:[aSavedClipping objectForKey:@"AppBundleURL"] atTimestamp:[[aSavedClipping objectForKey:@"Timestamp"] integerValue]]; diff --git a/UI/BezelWindow.m b/UI/BezelWindow.m index 92d000a..67ff287 100755 --- a/UI/BezelWindow.m +++ b/UI/BezelWindow.m @@ -199,6 +199,7 @@ - (id)initWithContentRect:(NSRect)contentRect [imagePreview setImageScaling:NSImageScaleProportionallyUpOrDown]; [imagePreview setImageAlignment:NSImageAlignCenter]; [imagePreview setImageFrameStyle:NSImageFrameNone]; + [imagePreview setAnimates:YES]; // play multi-frame GIFs in the preview [imagePreview setHidden:YES]; [[self contentView] addSubview:imagePreview]; isShowingImage = NO; From 8c58187871af5f0800c87ab8e013ca8c1f4e61fc Mon Sep 17 00:00:00 2001 From: Milan Hoppe Date: Fri, 3 Jul 2026 04:10:14 +0200 Subject: [PATCH 3/4] Credit Ankush Vangari for image support in acknowledgements Co-Authored-By: Claude Fable 5 --- acknowledgements.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/acknowledgements.txt b/acknowledgements.txt index 4be1a51..da2ae02 100644 --- a/acknowledgements.txt +++ b/acknowledgements.txt @@ -8,6 +8,8 @@ This fork was updated by haad to compile on Tahoe 26.0 release of Mac OS. © General Arcade, 2011 - 2020. © Adam Hamsik, 2025 - 2026. +Image clipboard support contributed by Ankush Vangari (), merged with gratitude under the MIT License. + Flycut incorporates the following libraries, used with gratitude: • ShortcutRecorder by Jesper et al. () • a modified version of UKPrefsPanel by Uli Kusterer () From 32851d2d7f76a71654c4771fa03553c6cc4723ae Mon Sep 17 00:00:00 2001 From: Milan Hoppe Date: Fri, 3 Jul 2026 04:24:54 +0200 Subject: [PATCH 4/4] Make animated-GIF download an explicit opt-in The GIF-preservation feature fetches the first URL found in copied HTML over the network, triggered automatically on copy. With it enabled by default, copying attacker-authored web content silently causes Flycut to issue an HTTPS request to an arbitrary host - a tracking/SSRF primitive, and a surprise for an app that otherwise makes zero network connections. Default the preference to NO so users must knowingly enable it. Co-Authored-By: Claude Fable 5 --- AppController.m | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/AppController.m b/AppController.m index fd5e566..ef7eeef 100755 --- a/AppController.m +++ b/AppController.m @@ -107,7 +107,10 @@ - (id)init @"captureImages", [NSNumber numberWithInt:10240], @"maxImageSizeKB", - [NSNumber numberWithBool:YES], + // Off by default: this feature issues a network request to a URL taken + // from copied HTML, which must be an explicit opt-in for a clipboard + // manager that otherwise never touches the network. + [NSNumber numberWithBool:NO], @"downloadAnimatedGIFs", nil]];