In my previous post, I gave a conceptual overview of what it takes to associate a file type on OS X. The key points were the following:
- File registrations are made locally in the application’s Info.plist file, not to a central registry. (A central registry – the ‘launch services’ database – still exists of course, it’s just that you don’t write to it directly.)
- A Mac GUI application, whether written using FMX or something else, must adopt some sort of non-SDI approach. This means when the user opens two or more files in the same application, they all share the same instance of the program. In an SDI model, in contrast, each open file has its own instance.
- The previous point rules out receiving file names on the command line. This is no different to Windows to the extent a ParamStr(1) approach assumes an SDI model. However, whereas on Windows a non-SDI approach means manually implementing single instancing and forwarding ‘open file’ messages to the original instance, on the Mac, these two things are done for you by the system.
- This still leaves actually receiving ‘open file’ messages. To do that, you must provide a ‘delegate object’ for NSApplication that includes an ‘application:openFile:’ method.
In the present post, I will put these points into practice in the context of an XE2/FMX application. The result will be a simple text file viewer that lists opened files down the side, TextWrangler 4.x-style:
In terms of functionality, the application will be the double click handler for files with a .tfvdoc extension, for which it will also provide an icon and type description. Aside from .tfvdoc files, the application will also accept any sort of plain text file that is dragged onto either the its main form or its Dock icon in. In the case of plain text files again, it will also be one of the applications listed by the ‘Open With’ sub-menu in Finder. The following screenshot shows this in the case of a CSS file:
Creating the application
To begin, create a new FMX HD application, and save the project file as TextFileViewer.dproj. With the main form in the designer, set each element of the Margins property to 4 and the Name property to frmMain. Following this, add these controls:
- A TListBox; set its Name to lsbFiles and its Align property to alLeft.
- A TSplitter; set its Align property also to alLeft.
- A TMemo; set its Name to memDisplay, its ReadOnly property to True, and its Align property to alClient.
- A TLabel; set is Name to lblFileName, its Text to an empty string, Padding.Left to 2, Padding.Top to 3, and Align to alBottom.
- A TMainMenu; add items for File|Open… and File|Export to .tfvdoc file… Rename the items itmOpen and itmExport respectively.
- A TOpenDialog called dlgOpen and a TSaveDialog called dlgExport. Set both of their Filter properties to ‘Text File Viewer Document (*.tfvdoc)|*.tfvdoc’ (no quotes).
Having set up the user interface, let’s now implement the routine for opening a file. In preparation for this, declare the following type just above the form class itself:
type TLoadedFile = class FileName, Contents: string; ListItem: TListBoxItem; end; TfrmMain = class(TForm) //...
After adding System.Generics.Collections to the interface section uses clause, declare the following items under the ‘private’ section of the form class:
private FLoadedFiles: TObjectDictionary<string, TLoadedFile>; procedure OpenFile(const AFileName: string);
Next, assign FLoadedFiles in a handler for the form’s OnCreate event, and free it in a handler for OnDestroy:
procedure TfrmMain.FormCreate(Sender: TObject); begin FLoadedFiles := TObjectDictionary<string, TLoadedFile>.Create([doOwnsValues]); end; procedure TfrmMain.FormDestroy(Sender: TObject); begin FLoadedFiles.Free; end;
This done, we can implement the OpenFile method itself. In it, we will first check to see whether the file is already open; if it is, then the relevant list box item is selected, otherwise a new list box item is added:
procedure TfrmMain.OpenFile(const AFileName: string); var LoadedFile: TLoadedFile; begin //have we already loaded this file? if FLoadedFiles.TryGetValue(AFileName, LoadedFile) then begin lsbFiles.ItemIndex := LoadedFile.ListItem.Index; Exit; end; //no, so load it LoadedFile := TLoadedFile.Create; try LoadedFile.Contents := TFile.ReadAllText(AFileName); LoadedFile.FileName := AFileName; LoadedFile.ListItem := TListBoxItem.Create(Self); LoadedFile.ListItem.TagObject := LoadedFile; LoadedFile.ListItem.Text := ExtractFileName(AFileName); lsbFiles.AddObject(LoadedFile.ListItem); except LoadedFile.ListItem.Free; LoadedFile.Free; raise; end; FLoadedFiles.Add(AFileName, LoadedFile); lsbFiles.ItemIndex := lsbFiles.Count - 1; itmExport.Enabled := True; end;
In order for this to compile, you will need to add System.IOUtils to either of the unit’s possible uses clauses.
Let’s now handle the list box’s OnChange event, itmOpen’s OnClick, and itmExport’s OnClick:
procedure TfrmMain.lsbFilesChange(Sender: TObject); var SelItem: TListBoxItem; LoadedFile: TLoadedFile; begin SelItem := lsbFiles.Selected; if SelItem = nil then Exit; LoadedFile := SelItem.TagObject as TLoadedFile; lblFileName.Text := LoadedFile.FileName; memDisplay.Text := LoadedFile.Contents; end; procedure TfrmMain.itmOpenClick(Sender: TObject); begin if dlgOpen.Execute then OpenFile(dlgOpen.FileName); end; procedure TfrmMain.itmExportClick(Sender: TObject); begin if not dlgSave.Execute then Exit; memDisplay.Lines.SaveToFile(dlgSave.FileName); ShowMessage('Exported to ' + dlgSave.FileName); end;
If you now run the application, you should be able to open files in it using the File|Open menu command. As that’s what you would at least expect, let’s now add the ability to open a file by dragging it onto the form from Finder. While not strictly to do with associating a file type, it is not a million miles away.
Allowing files to be dragged onto a running instance of the application
Happily enough, FMX explicitly implements support for draging files onto a form. As designed, the idea is that the control currently being hovered over determines whether a drop can take place. In our case we just want things done at the form level though, since otherwise, we will have to ensure the same OnDragOver and OnDragDrop handlers are assigned for potentially many different controls. Moreover, handling things at the form level can also be slightly more efficient.
So, back in the form’s class definition, add a private Boolean field called FHandleDragDirectly, together with the following public overrides:
procedure DragEnter(const Data: TDragObject; const Point: TPointF); override; procedure DragOver(const Data: TDragObject; const Point: TPointF; var Accept: Boolean); override; procedure DragDrop(const Data: TDragObject; const Point: TPointF); override;
The methods can then be implemented like this:
procedure TfrmMain.DragEnter(const Data: TDragObject; const Point: TPointF); begin FHandleDragDirectly := (Data.Files <> nil); if not FHandleDragDirectly then inherited; end; procedure TfrmMain.DragOver(const Data: TDragObject; const Point: TPointF; var Accept: Boolean); begin if FHandleDragDirectly then Accept := True else inherited; end; procedure TfrmMain.DragDrop(const Data: TDragObject; const Point: TPointF); var S: string; begin if FHandleDragDirectly then for S in Data.Files do OpenFile(S) else inherited; end;
If you re-run the application, you should now be able to drag and drop files from Finder onto the form. However, attempts to drag files onto the application’s icon in the Dock will fail. Let’s now begin fixing that by registering the application with relevant file types.
Associating file types in the plist file
Associating an application with one or more file types means editing its Info.plist file. On that score, there is good news and bad news. The good news is that you can set custom plist entries using nothing but the Delphi/RAD Studio IDE – specifically, the ‘Version Info’ node in Project Options is repurposed for the task when targeting OS X. However, the bad news is that file type information specifically requires setting nested plist keys, and the IDE isn’t flexible enough for that. Consequently, we need to deploy a custom Info.plist file maintained independently of the IDE.
Now in itself, a Info.plist is just a simple XML file. If you want, you can therefore edit it directly in something like Notepad++ or TextWrangler, or even the Delphi/RAD Studio IDE (small tip in the IDE’s case: add .plist as an XML source extension under Tools|Options, Editor Options → Source Options). Alternatively, on a Mac, you can use the lightweight Property List Editor or the rather more heavyweight Xcode, in which case you will have some assistance in both formatting the file correctly and even choosing the correct keys. On the other hand, these dedicated plist editing tools make copying and pasting groups of keys more tedious.
In either case, you will probably want to start with the Info.plist file the IDE has generated for you. To retrieve it, head into the IDE’s output folder on your Mac (mine is /Users/chrisrolliston/Applications/Embarcadero/PAServer/scratch-dir/CCR-iMac); if using Finder, then right click on TextFileViewer and choose Show Package Contents, before heading into the Contents sub-folder. Info.plist will then be waiting for you; once found, copy it into your project’s source directory.
Once you have done that, two branches of keys need to be added to the file: a CFBundleDocumentTypes structure to declare what file types the application can open, and a UTExportedTypeDeclarations one to declare information about any custom file types.
With the plist file open in a regular text editor, let’s first declare that the application can open ‘plain text’, source code, and XML files, and should be the primary application for a file type we’ve just made up (.tfvdoc files):
<key>CFBundleDocumentTypes</key> <array> <dict> <key>CFBundleTypeRole</key> <string>Editor</string> <key>LSItemContentTypes</key> <array> <string>public.plain-text</string> <string>public.source-code</string> <string>public.xml</string> </array> </dict> <dict> <key>CFBundleTypeRole</key> <string>Editor</string> <key>LSItemContentTypes</key> <array> <string>com.example.textfileviewer.tfvdoc</string> </array> <key>LSHandlerRank</key> <string>Owner</string> <key>CFBundleTypeIconFile</key> <string>tfvdoc.icns</string> </dict> </array>
You can add this code either before or below the keys outputted by the IDE – it doesn’t matter, so long as the CFBundleDocumentTypes key node is a child of the dict element. Now let’s add the section detailing the custom file type:
<key>UTExportedTypeDeclarations</key> <array> <dict> <key>UTTypeIdentifier</key> <string>com.example.textfileviewer.tfvdoc</string> <key>UTTypeConformsTo</key> <array> <string>public.plain-text</string> </array> <key>UTTypeDescription</key> <string>TextFileViewer document</string> <key>UTTypeIconFile</key> <string>tfvdoc.icns</string> <key>UTTypeTagSpecification</key> <dict> <key>public.filename-extension</key> <array> <string>tfvdoc</string> </array> </dict> </dict> </array>
This can go immediately below the CFBundleDocumentTypes structure. The ‘Universal Type Identifier’ (UDI) should generally be of the form com.companyname.appname.filekind. Having chosen it, the UTTypeConformsTo key then establishes the parent type. Here, the parent of something like public.jpeg is public.image and so on (see Apple’s docs for more information). In practical terms, parenting our type to public.plain-text has the effect of adding any application registered to open plain text files to the Open With menu for *.tfvdoc files too; it also allows a *.tfvdoc file to be dropped onto the Dock icon of any such application. Conversely, the fact we registered ourselves as an opener of plain text files in the CFBundleDocumentTypes structure means our own application will be added to the Open With list of any other type parented to public.plain-text, and likewise allow files of such a type to be dropped onto our Dock icon.
Aside from file type keys, another thing you may want to add is a CFBundleShortVersionString key. This is because the IDE mistakenly confuses CFBundleVersion with it; while an easy mistake to make, it can cause Finder to refer to the application as ‘TextFileViewer ()’ rather than ‘TextFileViewer (1.0.0)’:
Deploying the revised Info.plist
Having saved the revised Info.plist file, you now need to configure the IDE to deploy it instead of the default one. Do this by choosing Project|Deployment from the IDE menu bar, then All configurations – OS X platform from the combo box at the top of the Deployment tab. Having done that, add the custom plist file and change its Remote Path to just ‘Contents\’ (no quotes). Then, untick the two previously-existing Info.plist items. In the same place, you should also add a Mac icon file – if you look at the file registration info above, you’ll see we’ve said we will provide an icon called ‘tfvdoc.icns’. Mac icons can be created using the Icon Composer utility bundled with Xcode; once you’ve created one, add it to the Deployment tab, setting the Remote Path to ‘Contents\Resources\’.
Having done all that, re-run the application from the IDE. If it all goes correctly, the custom Info.plist will now be deployed. However, OS X is likely to have kept information from the old one cached. To invalidate that cache, close the application before issuing the following command at a Terminal window, replacing the path to the application’s bundle as appropriate:
/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Versions/Current/Support/lsregister -f /Users/chrisrolliston/Applications/Embarcadero/scratch-dir/CCR-iMac/TextFileViewer.app
Re-run the application, and you should now be able to drop text files onto the application’s dock icon. However, doing so will result an error message like the following:
A similar message will appear if you create a *.tfvdoc file and double click its icon in Finder. The reason? We aren’t handling the ‘open file’ message the system is sending us! That final part of the puzzle I’ll cover next time.