MacOS: how to get "Last opened" attribute of file? - swift

In some files in OS exist "Last opened" attribute:
modified and opened attribute is possible to get by the following way:
//modified date
try? FileManager.default.attributesOfItem(atPath: url.path)[FileAttributeKey.modificationDate] as? Date
//creation date
try? FileManager.default.attributesOfItem(atPath: url.path)[FileAttributeKey.creationDate] as? Date
But how to get "Last opened" date?

AFAIK, there is no way to get the last time the file was opened. Instead, you have to get the last time it was last read, written or its directory entry was modified.
Leo's suggestion in comments for another answer to use URLResourceValues.contentAccessDate is probably the cleanest way, especially since you already have a URL, which is typically the case these days.
func lastAccessDate(forURL url: URL) -> Date?
{
return try? url.resourceValues(
forKeys: [.contentAccessDateKey]).contentAccessDate
}
You can also reach down into the BSD layer using the path:
import Darwin // or Foundation
func lastAccessDate(forFileAtPath path: String) -> Date?
{
return path.withCString
{
var statStruct = Darwin.stat()
guard stat($0, &statStruct) == 0 else { return nil }
return Date(
timeIntervalSince1970: TimeInterval(statStruct.st_atimespec.tv_sec)
)
}
}
I'm not 100% of the of the behavior of resourceValues if the URL specified is a symbolic link, but stat() will return information about the file system inode pointed to by the link. If you want information directly about the link itself, use lstat() instead. stat() and lstat() are the same otherwise.
I'm pretty sure that URLResourceValues.contentAccessDate uses either stat() or lstat() under the hood.
One thing to keep in mind is that the last access time is not the last time the file was opened, but rather the last time it was read. The the man page for stat says:
The time-related fields of struct stat are as follows:
st_atime Time when file data last accessed. Changed by the mknod(2), utimes(2) and read(2) system calls.
st_mtime Time when file data last modified. Changed by the mknod(2), utimes(2) and write(2) system calls.
st_ctime Time when file status was last changed (inode data modification). Changed by the chmod(2), chown(2), link(2), mknod(2), rename(2), unlink(2),
utimes(2) and write(2) system calls.
st_birthtime Time of file creation. Only set once when the file is created. This field is only available in the 64 bit inode variants. On filesystems where
birthtime is not available, this field is set to 0 (i.e. epoch).
There the man page is referring to the 32-bit member field names, but the same would apply to the 64-bit names, st_atimespec, st_mtimespec, st_ctimespec, and st_birthtimespec.
To appromixate the getting the last time a file was opened, you'd want to get the the latest of st_atimespec, st_mtimespec and maybe st_ctimespec if you also want to include include changes to the directory entry that don't modify the contents, such as renaming the file or setting its permissions. So you'd need something like this:
func lastReadOrWrite(forFileAtPath path: String) -> Date?
{
return path.withCString
{
var statStruct = Darwin.stat()
guard stat($0, &statStruct) == 0 else { return nil }
let lastRead = Date(
timeIntervalSince1970: TimeInterval(statStruct.st_atimespec.tv_sec)
)
let lastWrite = Date(
timeIntervalSince1970: TimeInterval(statStruct.st_mtimespec.tv_sec)
)
// If you want to include dir entry updates
let lastDirEntryChange = Date(
timeIntervalSince1970: TimeInterval(statStruct.st_ctimespec.tv_sec)
)
return max( lastRead, max(lastWrite, lastDirEntryChange) )
}
}
or using URLResourceValues
func lastReadOrWriteDate(forURL url: URL) -> Date?
{
let valKeys: Set<URLResourceKey> =
[.contentAccessDateKey, .contentModificationDateKey, .attributeModificationDateKey]
guard let urlVals = try? url.resourceValues(forKeys:valKeys)
else { return nil }
let lastRead = urlVals.contentAccessDate ?? .distantPast
let lastWrite = urlVals.contentModificationDate ?? .distantPast
// If you want to include dir entry updates
let lastAttribMod = urlVals.attributeModificationDate ?? .distantPast
return max(lastRead, max(lastWrite, lastAttribMod))
}
Of course, if some process simply opens a file and then closes it without reading or writing, that will go unnoticed, but then if it didn't read or write, does it matter that it opened the file?

Related

Link app object to file on disk with metadata

Following this topic : iOS PDFkit cannot add custom attribute to pdf document
My app is using PDFKit to save files
I'm trying to set custom key metadata to PDFDocument I save on the device.
The object in my app ('Test') has two important properties :
id: a UUID to be able to retrieve the file on disk (the linked file on disk URL is this_UUID.jpg).
name: a human-readable string set by the user.
This cause some problems :
the file name is a UUID not human readable, so it's bad user experience.
If the user renames the file, the app won't be able to get the file.
So the id is to have a human-readable label for the file. So when the user opens the File app he can find it easily. And add metadata with the id so my app can retrieve it even if renamed. Looks like a nice solution right?
// First I create my own attribute
fileprivate extension PDFDocumentAttribute {
static let testId = PDFDocumentAttribute(rawValue: "com.sc.testID")
}
// Then I set the news attributes before saving the file, in the 'test' class
func setDocument(pdf: PDFDocument) {
let fileURL = self.getPDFDocumentURL()
print("the metadata is \(pdf.documentAttributes)") // print an empty dictionary
pdf.documentAttributes?[PDFDocumentAttribute.testId] = self.id
pdf.documentAttributes?[PDFDocumentAttribute.titleAttribute] = self.name // I suppose the ddisplay name of the document ? It's not, so what is that ?
print("the metadata is now \(pdf.documentAttributes)") // both are printed, it looks ok
//pdf.write(to: fileURL) // I tested this one too, same issues
let data = pdf.dataRepresentation()
do {
try data?.write(to: fileURL, options: .completeFileProtection)
} catch {
print(error.localizedDescription)
}
}
From here it looks ok, when I want to retrieve the pdf document I will check in the folder the id of each doc and return the doc when id match. But the problem is when I get the documentAttributes the attribute 'testId' isn't in. Note the native title, is set correctly.
So I could get the id from there but that looks pretty inappropriate
//still in 'Test' class
func getPDFDocument() -> PDFDocument? {
// get all docs in the folder ad check metadata for each
let fileManager = FileManager.default
let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0]
do {
let fileURLs = try fileManager.contentsOfDirectory(at: SchoolTest.getSubjectFolderURL(subject: self.subject!), includingPropertiesForKeys: nil)
for url in fileURLs {
print("the doc attributes are : \(PDFDocument(url: url)?.documentAttributes)") // contain title and others preset by Apple but not my custom 'testId'
if let doc = PDFDocument(url: url), doc.documentAttributes?[PDFDocumentAttribute.titleAttribute/*testId*/] as? String == self.documentName {
return doc // this work temporary
}
}
} catch {
print("Error while enumerating files \(documentsURL.path): \(error.localizedDescription)")
}
return nil
}
Display name:
Currently, the display name/label displayed in the File app is the file name (from URL).
This can cause problems too because if two 'Test' have the same name, their linked file gonna have the same URL. So when the most recent one will be saved on disk it will overwrite the other.
That's a problem I don't have when using the 'Test' id property for the file URL.
If I could set a display name for the file and keep the URL with the UUID that should resolve the problem.
Directories have the same localizing issue, I have named them with English but Apple native directories are localized. A localized name could be nice for user experience.
After hours of research, I can't find a way to localize or apply a display name to my files/directories.
// I tried that without positive result
var url = fileURL as NSURL
try url.setResourceValue("localized label", forKey: .localizedLabelKey)
print("localized name is \(try url.resourceValues(forKeys: [.localizedLabelKey]))")
let newURL = url as URL
try data?.write(to: newURL, options: .completeFileProtection)
Am I doing something badly? How should we do when adding custom metada to a file?

Validity time of the URL from an NSItemProvider

I’m writing a sharing extension that will accept images and perform some action with them. Within a method of my UIViewController subclass, I can access URLs to a particular representation of the files by writing this:
guard let context = self.extensionContext else {
return
}
guard let items = context.inputItems as? [NSExtensionItem] else {
return
}
for item in items {
guard let attachments = item.attachments else {
continue
}
for attachment in attachments {
guard attachment.hasItemConformingToTypeIdentifier("public.jpeg") else {
continue
}
attachment.loadFileRepresentation(forTypeIdentifier: "public.jpeg") { (url, error) in
if let url = url {
// How long is this "url" valid?
}
}
}
}
In the block I pass to loadFileRepresentation(forTypeIdentifier:completionHandler:), I’m given the URL to a file—in this case, a JPEG. Can I assume anything about how long this URL is valid? Specifically, is it safe to write the URL itself to some shared storage area so that my app can access the pointed-to file later? Or should I assume that the URL is ephemeral and that, if I want access to the file it points at, I should make my own copy of that file within this block?
The documentation for loadFileRepresentation states:
This method writes a copy of the file’s data to a temporary file, which the system deletes when the completion handler returns.
So url is valid to the closing curly brace of the completion handler.
You need to copy the file to a known location with the sandbox before you are done in the completion handler if you need access to the file beyond the completion handler.

macOS - How to have NSSavePanel to add a file extension in the file name?

I'm using this code to give the user the choice to specify a name and a location where to save a plain text file on disk. All seems to work but the saved file hasn't any extension. Actually I have not specify an extension in any part of my code, I read NSSavePanel documentation without notice the part where explained this option.
Here is the code I'm using:
let textToExport = mainTextField.textStorage?.string
if textToExport != "" {
let mySave = NSSavePanel()
mySave.begin { (result) -> Void in
if result == NSFileHandlingPanelOKButton {
let filename = mySave.url
do {
try textToExport?.write(to: filename!, atomically: true, encoding: String.Encoding.utf8)
} catch {
// failed to write file (bad permissions, bad filename etc.)
}
} else {
NSBeep()
}
}
}
Add the line
mySave.allowedFileTypes = ["txt"]
before presenting the panel.
From the documentation:
The value of this property specifies the file types the user can save
the file as. A file type can be a common file extension, or a UTI. The
default value of this property is nil, which indicates that any file
type can be used. (Note that if the array is not nil and the array
contains no items, an exception is raised.)
If no extension is given by the user, the first item in the
allowedFileTypes array will be used as the extension for the save
panel. If the user specifies a type not in the array, and
allowsOtherFileTypes is true, they will be presented with another
dialog when prompted to save.

How do I retrieve all available Finder tags?

I'm trying to retrieve a list of all the available Finder tags.
I found NSWorkspace().fileLabels, which does return an array, but only an array of the tag colours, not the tags themselves:
print(NSWorkspace.shared().fileLabels) // prints ["None", "Gray", "Green", "Purple", "Blue", "Yellow", "Red", "Orange"]
Which as you can see is not even all the default tags, it's missing Home, Work and Important, and obviously doesn't have any of the custom ones that I created. It looks like it's just the nice names that go with fileLabelColors.
I found NSMetadataQuery for actually searching for things, but how do I get a list of all the tags I have created in the Finder?
After some digging with "Hopper Disassembler" and my own "Find Any File" (for text search in files), I figured out where the Tags are now stored since Monterey:
The file ~/Library/SyncedPreferences/com.apple.kvs/com.apple.KeyValueService-Production.sqlite contains the same plist data that was previously stored in ~/Library/SyncedPreferences/com.apple.finder.plist.
But it's now hidden inside a database record:
If you look into the ZSYSDMANAGEDKEYVALUE table, you'll find a single entry with ZKEY="FinderTagDict". The ZPLISTDATAVLUE contains a bplist record, which contains the pblist (binary plist) structure, from which you can then extract the tags.
NSWorkspace.shared().fileLabels only returns the system tags that were available when the user account was created (the default system tags).
There's unfortunately no API in macOS to retrieve the tags that you have created yourself in the Finder.
The solution is to parse the ~/Library/SyncedPreferences/com.apple.finder.plist:
func allTagLabels() -> [String] {
// this doesn't work if the app is Sandboxed:
// the users would have to point to the file themselves with NSOpenPanel
let url = URL(fileURLWithPath: "\(NSHomeDirectory())/Library/SyncedPreferences/com.apple.finder.plist")
let keyPath = "values.FinderTagDict.value.FinderTags"
if let d = try? Data(contentsOf: url) {
if let plist = try? PropertyListSerialization.propertyList(from: d, options: [], format: nil),
let pdict = plist as? NSDictionary,
let ftags = pdict.value(forKeyPath: keyPath) as? [[AnyHashable: Any]]
{
return ftags.flatMap { $0["n"] as? String }
}
}
return []
}
let all = allTagLabels()
print(all)
This gets all Finder tags labels.
You can also select only the custom tags (ignore the system ones):
func customTagLabels() -> [String] {
let url = URL(fileURLWithPath: "\(NSHomeDirectory())/Library/SyncedPreferences/com.apple.finder.plist")
let keyPath = "values.FinderTagDict.value.FinderTags"
if let d = try? Data(contentsOf: url) {
if let plist = try? PropertyListSerialization.propertyList(from: d, options: [], format: nil),
let pdict = plist as? NSDictionary,
let ftags = pdict.value(forKeyPath: keyPath) as? [[AnyHashable: Any]]
{
return ftags.flatMap { tag in
if let n = tag["n"] as? String,
tag.values.count != 2
{
return n
}
return nil
}
}
}
return []
}
This is an attempt to solve the question for Monterey (and it also works in earlier systems such as High Sierra).
It might also work in sandboxed apps, as long as you're allowed to use System Events in scripts (I know from own experience that it's generally not allowed for apps in the Mac App Store, though). At worst, you'd have to support user-installed scripts and then instruct your users to install the script below manually (and I also suggest to avoid bothering the user with this operation within the first hour since the first launch of the app, or the reviewer may get triggered and reject your app regardless).
The best I could come up with is to use AppleScript to read the tag names from the Finder's Preferences window, see below.
But it's lame because it has to actually open the Finders's Prefs window briefly for this. Still, better than nothing.
use AppleScript version "2.4" -- Yosemite (10.10) or later
use scripting additions
set prefsWinWasOpen to false
tell application "Finder"
set prefs to Finder preferences
set prefsWin to window of prefs
tell prefsWin
try
get id
set prefsWinWasOpen to true
on error
set prefsWinWasOpen to false
open
end try
set current panel to Label Preferences panel
end tell
end tell
set rowNames to {}
tell application "System Events"
tell front window of (first application process whose name is "Finder")
set uiElems to entire contents
repeat with uiElem in uiElems
if class of uiElem is table then
set theTable to uiElem
exit repeat
end if
end repeat
set itsRows to rows of theTable
repeat with oneRow in itsRows
set end of rowNames to name of first UI element of oneRow
end repeat
end tell
end tell
if not prefsWinWasOpen then
tell application "Finder"
close prefsWin
end tell
end if
return rowNames

Calling Swift's readline to the same variable twice ignores the second call

I'm reading user input from command line, checking if it's a valid file path and—if it's not—asking the user to try again.
In case the user input is nil, it should be treated as any other incorrect input the first time, letting the user enter a new value, but the second time that nil is entered, the program should force quit.
(I assume that a nil value is something a user won’t enter on purpose so if it happens more than twice I assume something has gone wrong and quit the program to avoid a never-ending loop asking for new input. This may or may not be a good way to do it but that doesn’t affect the question.)
The problem is that readLine() doesn’t ask for user input the second time it is called after if it has received an end-of-line input (which yields the nil value). (End-of-line can be input with ^D.)
This means the function where readLine() is located automatically returns nil because that’s the latest value of the variable that receives the readLine().
Questions
Shouldn’t readLine() be called no matter what value the receiving variable already has?
If so, why isn't the user asked for input when nil has been input once?
This is the code:
import Foundation
/**
Ask the user to provide a word list file, check if the file exists. If it doesn't exist, ask the user again.
*/
func askUserForWordList() -> String? {
print("")
print("Please drag a word list file here (or enter its path manually), to use as basis for the statistics:")
var path = readLine(stripNewline: true) // THIS IS SKIPPED IF "PATH" IS ALREADY "NIL".
return path
}
/**
Check the user input // PROBABLY NOT RELEVANT FOR THIS QUESTION
*/
func fileExists(filePath: String) -> Bool {
let fileManager = NSFileManager.defaultManager()
if fileManager.fileExistsAtPath(filePath) {
return true
} else {
return false
}
}
/**
Get the file from the user and make sure it’s valid.
*/
func getFilePathFromUser() throws -> String {
enum inputError: ErrorType {
case TwoConsecutiveEndOfFiles
}
var correctFile = false
var path: String? = ""
var numberOfConsecutiveNilFiles = 0
repeat {
// Check that the user did not enter a nil-value (end-of-file) – if they did so two times in a row, terminate the program as this might be some kind of error (so that we don't get an infinite loop).
if numberOfConsecutiveNilFiles > 1 { // FIXME: entering ^D once is enough to end the program (it should require two ^D). Actually the problem seems to be in function "askUserForWordList()".
throw inputError.TwoConsecutiveEndOfFiles
}
path = askUserForWordList()
if path == nil {
numberOfConsecutiveNilFiles += 1
} else {
numberOfConsecutiveNilFiles = 0
correctFile = fileExists(path!)
if !correctFile {
print("")
print("Oops, I couldn't recognize that file path. Please try again.")
}
}
} while !correctFile
return path!
}
// This is where the actual execution starts
print("")
print("=== Welcome to \"Word Statistics\", command line version ===")
print("")
print("This program will give you some statistics for the list of words you provide.")
do {
let path = try getFilePathFromUser()
} catch {
print("Error: \(error)")
exit(-46) // Using closest error type from http://www.swiftview.com/tech/exitcodes.htm (which may not be standard at all. I could, however, not find any "standard" list of exit values).
}
Notes
When entering any other non-valid path (any string, even an empty one (just press Enter), the loop works as intended.
Originally path in the askUserForWordList() function was declared as a constant (let path = readLine(stripNewline: true)) but I changed it to a var since it’s supposed to be updated everytime the function is called. However, this didn’t affect how the program works.
I tried declaring path separately on the line before calling readLine(), which made no difference.
I tried skipping the path variable completely and let the askUserForWordList() function return the readLine() result directly (return readLine(stripNewline: true)). This made no difference.
I skipped the askUserForWordList() function all together and moved the code asking for user input into the ”main” code of the function "getFilePathFromUser()", but that didn’t change anything.
Modified code:
func getFilePathFromUser() throws -> String {
enum inputError: ErrorType {
case TwoConsecutiveEndOfFiles
}
var correctFile = false
var path: String? = ""
var numberOfConsecutiveNilFiles = 0
repeat {
// Check that the user did not enter a nil-value (end-of-file) – if they did so two times in a row, terminate the program as this might be some kind of error (so that we don't get an infinite loop).
if numberOfConsecutiveNilFiles > 1 { // FIXME: entering ^D once is enough to end the program (it should require two ^D). Actually the problem seems to be in function "askUserForWordList()".
throw inputError.TwoConsecutiveEndOfFiles
}
// MODIFIED – This code was previously located in "askUserForWordList()"
print("")
print("Please drag a word list file here (or enter its path manually), to use as basis for the statistics:")
path = readLine(stripNewline: true)
// END OF MODIFICATION
if path == nil {
numberOfConsecutiveNilFiles += 1
} else {
numberOfConsecutiveNilFiles = 0
correctFile = fileExists(path!)
if !correctFile {
print("")
print("Oops, I couldn't recognize that file path. Please try again.")
}
}
} while !correctFile
return path!
}
readLine() returns nil if (and only if) the standard input file descriptor has reached end-of-file. This happens (for example) when reading the input
from a tty, and Ctrl-D (the "end-of-transmission character") is entered as the first character on a line.
All subsequent readLine() calls then return nil as well, there is no
way detect that "Ctrl-D was entered twice".
In other words, once the standard input is in the end-of-file condition
you cannot read any data from it anymore. If your programs requires
more data then you can only report an error, e.g.
guard let path = readLine(stripNewline: true) else {
throw InputError.UnexpectedEndOfFile
}