How Do I Get Recent Files of All Apps?

asobjc

(Jim Underwood) #1

I have the below script, probably written by @ShaneStanley, that will return the recent files for the frontmost app. How can I change this to provide these options:

  1. All apps
  2. Only files that were modified
  3. Only files that were opened, but not modified
  4. Only files that were created (have not been modified)
  5. Only files opened by a list of apps (think “Fav apps”, “Recent Apps”, etc).

which leads me to these questions: How do I get a list of recent apps and open apps?
I know how to get open apps using System Events, but I thought there might be a better way.

Finally, is there a way to distinguish between script files that have had the source changed vs just being recompiled?

In case you’re curious, Keyboard Maestro uses osascript file, and this causes the file to be recompiled and thus the modification date updated. I’d like to identify only those script files whose source has been changed.

Can osascript be used without recompiling the script file?

BTW, I did research NSPredicate, including Shane’s book Everyday AppleScriptObjC, Third Edition, but could not figure this out.

Here’s the script:

use AppleScript version "2.4" -- Yosemite (10.10) or later
use framework "Foundation"
use scripting additions

#  AUTHOR: Probably @ShaneStanley and/or @ccstone

-------------------------------------------------------------------------------------------
#### USER SETTINGS ####
-------------------------------------------------------------------------------------------

set dataFilePath to "~/Library/Application Support/com.apple.sharedfilelist/com.apple.LSSharedFileList.ApplicationRecentDocuments/com.apple.scripteditor2.sfl"
set outputType to "file_path" -- the alternatives are two: "file_path" and "folder_path"

-------------------------------------------------------------------------------------------

set dataFilePath to (current application's NSString's stringWithString:dataFilePath)'s stringByExpandingTildeInPath
set theData to the current application's NSData's dataWithContentsOfFile:dataFilePath
set {theDict, theError} to current application's NSPropertyListSerialization's propertyListWithData:theData ¬
  options:(current application's NSPropertyListMutableContainersAndLeaves) |format|:(missing value) |error|:(reference)
if theDict = missing value then error (theError's localizedDescription() as text)
set theValues to (theDict's valueForKey:"$objects")
set thePred to current application's NSPredicate's ¬
  predicateWithFormat:"(self isKindOfClass: %@) AND (self BEGINSWITH %@)" argumentArray:{current application's NSString's |class|(), "file:///"}
set theFiles to (theValues's filteredArrayUsingPredicate:thePred)
set theSet to current application's NSMutableSet's |set|()

repeat with aFile in theFiles
  if outputType is "folder_path" then -- return container folder path of each recent file:
    (theSet's addObject:((current application's |NSURL|'s URLWithString:aFile)'s |path|()'s stringByDeletingLastPathComponent()))
  else if outputType is "file_path" then -- return full path of each recent file:
    (theSet's addObject:((current application's |NSURL|'s URLWithString:aFile)'s |path|()))
  end if
end repeat

# Remove any duplicate entries:
set outputPathList to theSet's allObjects() as list -- returns POSIX Paths

set AppleScript's text item delimiters to linefeed

return outputPathList as text

TIA.


(Shane Stanley) #2

The OS has stopped using this method to store recent files and apps since that script was written, and I haven’t really looked at what it uses now.

No.

Just to be clear, this is not really an issue with osascript. It’s normal for an app, after it has run a script, to check if anything has changed, and if so update the file on disk. If it didn’t, properties would not be persistent. I’m not sure if KM or osascript is doing it, but it’s standard behavior.

Two ideas to consider. First, using version numbers. Second, stop the scripts from being saved after running. You can do this by changing permissions, but you can also do it by avoiding changes to any top-level variables, or going the other way and setting one to a Cocoa object when the script runs.


(Phil Stokes) #3

Don’t know about better, but different:

do shell script "lsappinfo list"

and also lsappinfo metainfo.


(Jim Underwood) #4

Thanks for the suggestions, Shane.

I’m already using version numbers, but that does not really help my issue. When I do a search for scripts, I often want to view the scripts in order of reverse mod date.

So, let’s talk about permissions. Is there a way I can make all files in a folder read only to a specific app, like KM, but still be writeable by SD?

I don’t know what that means. Could you please give me an example?

Thanks, Phil.


(Jim Underwood) #5

Thanks, Phil.

After testing both of your shell scripts, I find this AppleScript to be simpler and faster:

tell application "System Events"
  set appNameList to name of every application process whose background only is false
end tell

For reference, here is the full script using lsappinfo required to produce the same results:

set appInfoStr to do shell script "lsappinfo find applicationtype=\"Foreground\""

--- Requires Satimage.osax ---
set regexFind to "ASN:.+?\"(.+?)\": ?"
set regexReplace to "\\1" & linefeed
set appNameTextList to change regexFind into regexReplace in appInfoStr syntax "PERL" with regexp

set AppleScript's text item delimiters to linefeed

set appNameList to text items of appNameTextList

(Shane Stanley) #6

Probably not. I suspect you’d have to change them each time you edit/save.

Suppose a script begins like this:

use AppleScript version "2.4" -- Yosemite (10.10) or later
use framework "Foundation"
use scripting additions

set x to current application's NSString

Once run, the top-level variable x is a Cocoa object so the script can’t be saved. (Try it in SD with Persistent Properties turned on.)


(Jim Underwood) #7

Thanks Shane! That is the perfect solution to my issue.
Just tested it with KM, and script file is NOT updated! :+1:


(Jim Underwood) #8

Shane, would you mind if we reexamined this?

The script in my OP still works, it is just limited to the current frontmost app.
It is easy enough to get a list of currently running apps, and with more difficulty, a list of recent apps. So, given a list of apps, is there a way to loop the script through each app without activating each app?

Actually, I just looked at the files in this folder:
~/Library/Application Support/com.apple.sharedfilelist/com.apple.LSSharedFileList.ApplicationRecentDocuments

Sorted on rev Mod Date, it looks like a list of my recent apps where I have opened a document:

image

Could we not just go through this list until our criteria are met (last 7 days, for ex.)?


(Shane Stanley) #9

I’m afraid I don’t understand what, exactly, you are after.


(Phil Stokes) #10

I won’t argue about the simplicity, but the speed drag is down to using an external osax to do regex instead of using native macOS APIs. You should find this faster and more reliable than calling on System Events:

use AppleScript version "2.4"
use scripting additions
use framework "Foundation"

property NSString : a reference to current application's NSString
property NSBackwardsSearch : a reference to 4
set foregroundApps to {}

set appText to (do shell script "lsappinfo find applicationtype=\"Foreground\"")
set appString to NSString's stringWithString:appText
set appList to appString's componentsSeparatedByString:"ASN:"
repeat with anApp in appList
	set aRange to (anApp's rangeOfString:"\"")
	set bRange to (anApp's rangeOfString:"\"" options:NSBackwardsSearch)
	try
		set appName to (anApp's substringWithRange:{(aRange's location) + (aRange's |length|), (((bRange's location) - 1) - (aRange's location))})
		set end of foregroundApps to appName as text
	end try
end repeat
foregroundApps


(Jim Underwood) #11

Shane, I’m still after the same thing I stated in the title of this topic:
How Do I Get Recent Files of All Apps?

But it’s not worth anyone spending a lot of time on. If the answer/solution doesn’t jump out at you, then just let me know, and I’ll move on. :wink:


(Shane Stanley) #12

That’s the case, I’m afraid.


(Phil Stokes) #13

On my 10.13.4, it looks pretty much like Recent Documents are tracked using com.apple.LSSharedFileList.RecentDocuments.sfl.

It’s a binary plist, but seems perfectly readable with plutil.

com.apple.LSSharedFileList.ApplicationRecentDocuments also still appears to be a thing and in use here.


(Jim Underwood) #14

Phil, how are you using plutil?
All I can get is hex or combo hex and text.
I tried the -p option, but got same.

EDIT
Sorry, I just saw the end of the list.
There is a lot readable, but it is VERY verbose.

How would you extract the file name, full path, and mod date from this?


(Shane Stanley) #15

It looks like I was getting methods confused, and that that file is still used.

It’s readable, but not particularly intelligible. If you look at it, you can see it’s been produced by NSKeyedArchiver, not NSPropertyListSerialization. And if you try to unarchive it, you get an error:

-[NSKeyedUnarchiver decodeObjectForKey:]: cannot decode object of class (SFLListItem) for key (NS.objects); the class may be defined in source code or a library that is not linked

In other words, an instance of a custom class (SFLListItem) has been serialized (presumably securely) and written to disk. Even if you knew how that class was implemented, picking out the pieces would be quite the challenge. The original script just plucked out all the URLs; I suspect anything more is going to be very tricky.


(Phil Stokes) #16

Sorry, on further investigation I think you may be on a hiding to nothing here.

First, those .sfl files don’t store dates, so I’m afraid you’re out of luck on that one. And as Shane says, parsing the data out of a custom class in any reasonable way with a script is going to be tricky (and fragile).

Second, looking at what’s in my folder, it doesn’t look like all applications are actually recording recent items.

About the best I can offer is a list of recent filenames (no paths, no parent apps), using strings and grep with something like this:

strings -a ~/Library/Application\ Support/com.apple.sharedfilelist/com.apple.LSSharedFileList.RecentDocuments.sfl2 | grep -B1 'file:///' | grep -v 'file:///'

(that’s a shell command, so wrap it in a do shell script and escape appropriately if you’re going to run it as an AppleScript)

but you may be able to do better yourself with your regex skills.

Addendum: you already indicated this wasn’t something you wanted to invest much time with, but I’ll just add for the sake of completion that you may be able to parse this info out of fsevents. I have an interest in macos forensics and have played with this a couple of times before, but you’ll need to roll your sleeves up :stuck_out_tongue:


(Nigel Garvey) #17

Hi.

Based largely on the information in this thread so far, here’s a handler to get the URLs of the currently open apps’ recent files. It returns an array of dictionaries, each dictionary representing an app and containing the app’s bundle ID and an array of file info dictionaries. Each of these info dictionaries contains a file URL and a boolean indicating whether or not the bookmark from which it was derived is “stale” — whatever that means. :wink: The hope is that the information you need can be gleened from the URLs’ resource values.

On my High Sierra machine, every SFL file dated since 15th November 2017 has an “.sfl2” extension, which seems to indicate a change of format around then. The script here works on both my 10.13.4 and 10.11.6 machines.

use AppleScript version "2.4" -- Mac OS 10.10 (Yosemite) or later.
use framework "Foundation"
use scripting additions

(*
	Return an array of dictionaries containing URLs for the recent files of all open foreground applications which have them.
 	Each dictionary represents an application and is in the form:
 	{docURLs:{{|url|:<file URL>, staleBookmark:<boolean>}, …}, appBundleID:<string>}
 *)
on getRecentFileURLsForOpenForegroundApps()
	-- Get the bundle IDs of the currently open foreground applications.
	set foregroundAppIDs to paragraphs of (do shell script "lsappinfo list | sed -En '
/^[[:space:]]+ bundleID=\"/ h
/type=\"Foreground\"/ {
	g
	s/^[^\"]+\"|\"[^\"]*$//g
	p
}'")
	
	set |⌘| to current application
	-- Get the POSIX paths of all visible items in ~/Library/Application Support/com.apple.sharedfilelist/com.apple.LSSharedFileList.ApplicationRecentDocuments/.
	set fileManager to |⌘|'s class "NSFileManager"'s defaultManager()
	set applicationRecentDocsFolderURL to |⌘|'s class "NSURL"'s fileURLWithPath:(POSIX path of (path to application support from user domain) & "com.apple.sharedfilelist/com.apple.LSSharedFileList.ApplicationRecentDocuments")
	set applicationRecentDocsSFLPaths to (fileManager's contentsOfDirectoryAtURL:(applicationRecentDocsFolderURL) includingPropertiesForKeys:({}) options:(|⌘|'s NSDirectoryEnumerationSkipsHiddenFiles) |error|:(missing value))'s valueForKey:("path")
	-- Filter for paths with ".sfl" extensions and names corresponding to the bundle IDs of the currently open foreground applications.
	-- The IDs in the paths are lower cased, whereas the bundle IDs proper may be mixed cased.
	set filteredSFLPaths to applicationRecentDocsSFLPaths's mutableCopy() -- This is filtered below.
	set foregroundAppIDs to |⌘|'s class "NSArray"'s arrayWithArray:(foregroundAppIDs)
	set foregroundAppSLFsOnlyFilter to |⌘|'s class "NSPredicate"'s predicateWithFormat:("(pathExtension BEGINSWITH[c] 'sfl') AND (lastPathComponent.stringByDeletingPathExtension IN %@)") argumentArray:({foregroundAppIDs's valueForKey:("lowercaseString")})
	tell filteredSFLPaths to filterUsingPredicate:(foregroundAppSLFsOnlyFilter)
	
	-- In the folder on my 10.13.4 system, all the SFL files dated on or after 15th November 2017 have ".sfl2" extensions. Possibly a new format, since the "items.Bookmark" key path below doesn't work with the ".sfl" files on my 10.11.6 machine. Here, filter out any obsolete path matches by working backwards through a reversed lexical sort of the matches and, where a later path is followed by an earlier version, removing the latter from the array.
	set reverseSortDescriptor to |⌘|'s class "NSSortDescriptor"'s sortDescriptorWithKey:("self") ascending:(false) selector:("caseInsensitiveCompare:")
	tell filteredSFLPaths to sortUsingDescriptors:({reverseSortDescriptor})
	repeat with i from (count filteredSFLPaths) - 1 to 1 by -1
		set thisSFLPath to (item i of filteredSFLPaths)
		if not ((thisSFLPath's hasSuffix:(".sfl")) as boolean) then
			set originalPath to (thisSFLPath's stringByDeletingPathExtension()'s stringByAppendingPathExtension:("sfl"))
			if (((item (i + 1) of filteredSFLPaths)'s hasPrefix:(originalPath)) as boolean) then tell filteredSFLPaths to removeObjectAtIndex:(i)
		end if
	end repeat
	
	-- Build an array of dictionaries, each dictionary containing an app bundle ID and an array of associated recent file URL info dictionaries.
	set outputArray to |⌘|'s class "NSMutableArray"'s new()
	repeat with thisSFLPath in filteredSFLPaths
		-- Unarchive the data from this SFL file and extract any bookmark data values.
		set unarchivedSFLData to (|⌘|'s class "NSKeyedUnarchiver"'s unarchiveObjectWithFile:(thisSFLPath))
		if ((thisSFLPath's hasSuffix:(".sfl")) as boolean) then -- Assuming this to be the criterion for the different case in the keypath.
			set bookmarkDataValues to (unarchivedSFLData's valueForKeyPath:("items.bookmark"))
		else
			set bookmarkDataValues to (unarchivedSFLData's valueForKeyPath:("items.Bookmark"))
		end if
		if ((count bookmarkDataValues) > 0) then
			-- If bookmark data values are found, create an array containing URL/staleBookmark dictionaries for each.
			set URLInfoArray to |⌘|'s class "NSMutableArray"'s new()
			repeat with theseBookmarkData in bookmarkDataValues
				set URLInfoDictionary to (|⌘|'s class "NSDictionary"'s dictionaryWithObjects:(|⌘|'s class "NSURL"'s URLByResolvingBookmarkData:(theseBookmarkData) options:(0) relativeToURL:(missing value) bookmarkDataIsStale:(reference) |error|:(missing value)) forKeys:{"URL", "staleBookmark"})
				tell URLInfoArray to addObject:(URLInfoDictionary)
			end repeat
			-- Get the original, possibly mixed-case bundle ID for the app associated with this SFL file.
			set IDMatchFilter to (|⌘|'s class "NSPredicate"'s predicateWithFormat:("self ==[c] %@") argumentArray:({thisSFLPath's lastPathComponent()'s stringByDeletingPathExtension()}))
			set appBundleID to (foregroundAppIDs's filteredArrayUsingPredicate:(IDMatchFilter))'s firstObject()
			
			-- Create a dictionary containing the bundle ID and URL info dictionary array for this app and add it to the output array.
			set appIDAndDocURLInfo to (|⌘|'s class "NSDictionary"'s dictionaryWithObjects:({appBundleID, URLInfoArray}) forKeys:({"appBundleID", "RecentDocURLInfo"}))
			tell outputArray to addObject:(appIDAndDocURLInfo)
		end if
	end repeat
	
	return outputArray
end getRecentFileURLsForOpenForegroundApps

getRecentFileURLsForOpenForegroundApps()

(Jim Underwood) #18

Nigel,

Many thanks for taking this to the next level. :+1:

Unfortunately, it does not seem to capture recent files of apps that don’t play according to Apple’s rules. :frowning_face:
I’m running macOS 10.12.6.

I have 16 apps open, but the script returns files for only 4:

com.latenightsw.ScriptDebugger7
com.cocoatech.PathFinder
com.apple.TextEdit
com.apple.ScriptEditor2

Apps Open

com.cocoatech.PathFinder
com.smileonmymac.textexpander
jp.co.pfu.ScanSnap.V10L10
com.apple.finder
org.mozilla.firefox
com.microsoft.Outlook
com.evernote.Evernote
com.stairways.keyboardmaestro.editor
at.obdev.LaunchBar
com.google.Chrome
com.apple.ScriptEditor2
com.apple.iChat
com.latenightsw.ScriptDebugger7
com.microsoft.Word
com.microsoft.Excel
com.apple.TextEdit

While Word and Excel maintain an extensive recent files list, they seem to do so using a different method.

This was a really good try, and I really appreciate your efforts.

BTW, this script will also return the bundle IDs of open apps:

tell application "System Events"
  set appIdList to bundle identifier of every application process whose background only is false
end tell

but I suspect you already know that. Just thought I’d post for the benefit of others.


(Nigel Garvey) #19

Pity :frowning: — although in fact most of the other twelve in your list either don’t have or probably don’t have “Open Recent” submenus in their “File” menus. I can confirm though that the URLs returned for TextWrangler are all NSNulls. The data values are there in the SFL2 file, but are obviously not straight bookmark data. TextWrangler’s/BBEdit’s “Open Recent” menu has a non-standard format.

This is actually safer than the lsappinfo/sed shell script I used, in which the sed code depends on the bundleID line coming before the type line in the lsappinfo result!


(Phil Stokes) #20

We can avoid that with a bit of Foundation help:

use AppleScript version "2.4"
use scripting additions
use framework "Foundation"

property NSString : a reference to current application's NSString
property NSCharacterSet : a reference to current application's NSCharacterSet
set foregroundBundleIDs to {}

set appText to NSString's stringWithString:(do shell script "lsappinfo list")
set appEntries to appText's componentsSeparatedByString:(return & return)
repeat with entry in appEntries
	set entryTxt to entry as text
	if entryTxt contains "Foreground" then
		set theLoc to (entry's rangeOfString:"bundleID=\"")
		set theLength to (entry's rangeOfString:"bundle path=")
		set theRange to current application's NSMakeRange((theLoc's location) + (theLoc's |length|), (theLength's location) - ((theLoc's location) + (theLoc's |length|)))
		set bundleID to (entry's substringWithRange:theRange)
		
		set bundleID to (bundleID's stringByTrimmingCharactersInSet:(NSCharacterSet's whitespaceAndNewlineCharacterSet()))
		set bundleID to (bundleID's stringByTrimmingCharactersInSet:(NSCharacterSet's punctuationCharacterSet()))
		set end of foregroundBundleIDs to bundleID as text
	end if
end repeat
foregroundBundleIDs