Однажды передо мной встала задача показать файлы с удаленного сервера нативно в Finder. Первое, что пришло на ум, — использовать WebDAV, но это был публичный сервис, к серверу которого у меня не было доступа, а в распоряжении имелся только REST API. В голове промелькнула мысль написать свою файловую систему, но она казалась слишком амбициозной. Однако мои сомнения развеялись, когда я обнаружил, что сообщество энтузиастов развивает проект FUSE for macOS, который сводит создание собственной файловой системы к нескольким сотням строк кода. А идущий в комплекте фреймворк не требует унылых разбирательств с API на голом C и вполне пригоден для использования в комплекте с современным и мощным Swift.
FUSE (Filesystem in Userspace) — это интерфейс для программ пространства пользователя, позволяющий экспортировать файловую систему ядру ОС. Этот механизм появился в Linux, и условно его можно разделить на два базовых компонента: модуль ядра (поддерживается разработчиками ядра) и библиотека пользовательского пространства (libfuse). Эта библиотека предоставляет методы для монтирования, размонтирования, отправки запросов к ядру и получения ответов от него. Также она реализует удобный верхнеуровневый API, в котором мы можем оперировать привычными понятиями имен файлов и путей, вместо работы с inode.
Проект FUSE for macOS представляет собой аналогичный набор API (а также Objective-C фреймворк), позволяющий реализовать полноценную файловую систему, которая будет работать в пространстве пользователя на macOS. Так как его API является надмножеством FUSE API из Linux, то существует теоретическая возможность завести многие из существующих файловых систем на macOS. В настоящее время этот проект остается единственной реализацией FUSE для macOS, которая развивается и поддерживается силами сообщества, хотя и активность на GitHub и в Google Groups сейчас довольно низкая.
Где еще используется FUSE?
Загрузка ...
Установка не отличается сложностью: скачиваешь инсталлятор с сайта разработчика и запускаешь его. Если предпочитаешь собирать такие вещи из исходников, то это тоже не составит труда: достаточно установить зависимости через brew и запустить сборочный скрипт, все это подробно описано в Readme на GitHub.
Создадим новый проект в Xcode. Это должно быть Cocoa Application (в разделе macOS), я назвал его HelloFuse, язык выберем Swift, остальные параметры можно выбрать на свое усмотрение.
После установки фреймворк будет расположен по следующему пути: /Library/Frameworks/OSXFUSE.framework
. Чтобы добавить его в проект, достаточно просто перетащить его в раздел Linked Frameworks and Libraries на вкладке General настроек сборки.
Так как мы пишем проект на Swift, а фреймворк реализован на Objective-C, то нам нужно создать и подключить так называемый Bridging Header. Создадим заголовочный файл (File > New > File > macOS > Source > Header File), назовем его HelloFuse-Bridging-Header.h
и добавим в него следующую строчку:
#import <OSXFUSE/OSXFUSE.h>
Теперь на панели навигации выбираем наш проект, выбираем сборку в разделе Targets, переходим на вкладку Build Settings, находим раздел Swift Compiler → General, в поле Objective-C Bridging Header добавляем
$(PROJECT_DIR)/$(TARGET_NAME)/HelloFuse-Bridging-Header.h
По умолчанию во всех приложениях включена песочница, которая ограничивает возможности приложения, но в отличие от iOS на macOS ее можно отключить. Этим ты потеряешь право распространять приложение через App Store (что тоже не будет проблемой в случае с macOS), но в нашем случае нам нужен полноценный доступ к файловой системе, поэтому выбора нет.
Перейдем на вкладку Capabilities в настройках сборки и поставим переключатель в пункте App Sandbox в положение OFF.
Поведение файловой системы описывается в отдельном классе. Создадим класс под названием HelloFS и унаследуем его от NSObject. В минимальном примере нам понадобится реализовать только два метода: получение списка файлов, который мы будем отображать, и содержимое каждого файла.
В методе, отвечающем за отображение файлов, нужно вернуть массив строк с именами файла. В качестве параметра туда приходит путь (path), в более сложных случаях нужно будет его обрабатывать, чтобы показывать контент соответствующей директории. Здесь я просто возвращаю один файл hello.txt.
override func contentsOfDirectory(atPath path: String) throws → [Any] { return ["hello.txt"] }
В метод, который отвечает за отображение пути файла, аналогично приходит путь, в зависимости от которого мы должны решить, какое содержимое отдавать для файла. В нашем же примере мы будем для всех файлов возвращать строку «Hello world!».
override func contents(atPath path: String) → Data? { return "Hello world!".data(using: .utf8) }
В итоге файл HelloFS.swift примет следующий вид:
import Foundation final class HelloFS: NSObject { override func contentsOfDirectory(atPath path: String) throws → [Any] { return ["hello.txt"] } override func contents(atPath path: String) → Data? { return "Hello world!".data(using: .utf8) } }
В классе AppDelegate объявим две переменные:
private var helloFS: HelloFS? private var userFileSystem: GMUserFileSystem?
В метод applicationDidFinishLaunching добавим следующий код:
helloFS = HelloFS() userFileSystem = GMUserFileSystem(delegate: helloFS, isThreadSafe: false) var options: [String] = ["rdonly", "volname=HelloVolume"] userFileSystem?.mount(atPath: "/Volumes/hello", withOptions: options)
Код достаточно интуитивен: инициализируем файловую систему, указываем название раздела и передаем параметр, что она только для чтения, а затем монтируем ее по указанному пути.
По завершении работы приложения в методе applicationWillTerminate
демонтируем нашу файловую систему:
userFileSystem?.unmount()
После запуска приложения наш раздел появится в директории /Volumes, a также должен быть виден и в корневой директории. В разделе будет лежать единственный файл hello.txt, в котором будет написано «Hello world!».
В чем преимущество использования виртуальных ФС?
Загрузка ...
Для раздела можно заменить иконку по своему усмотрению, для этого в проект нужно положить иконку в формате *.icns (требования к размерам и прочему можно найти в macOS Design Guidelines) и добавить путь к ней в массив options.
if let volumeIconPath = Bundle.main.path(forResource: "disk", ofType: "icns") { options.insert("volicon=(volumeIconPath)", at: 0) }
Давай попрактикуемся и реализуем то, для чего в большинстве случаев создаются подобные файловые системы, — отображение контента с удаленного сервера в виде файлов и папок. Предлагаю отобразить таким образом альбомы и фотографии из паблика нашего журнала в VK. Пример можно будет легко адаптировать для своих нужд, так как мы не будем завязываться на SDK «ВКонтакте», а будем обращаться напрямую к методам REST API, доступным без авторизации.
Мы будем оперировать двумя сущностями: альбом и фотография. Альбомы будут лежать в корне нашей файловой системы и выглядеть как папки, а фотографии находиться в альбомах и выглядеть, соответственно, как файлы картинок. Наши модели должны удовлетворять протоколу Decodable, для того чтобы мы могли их распарсить из JSON, который мы получим с сервера.
Для альбома нам понадобится знать его идентификатор, чтобы потом по нему запросить фотографии, а также его название.
struct Album: Decodable { let id: Int let title: String var photos: [Photo] = [] private enum CodingKeys : String, CodingKey { case id, title } }
Для фотографий нам нужно знать URL, по которому мы будем скачивать фотографию, а также имя файла. Так как в VK нет отдельного заголовка для фотографий, я буду использовать имя файла из URL.
struct Photo: Decodable { let url: String let filename: String private enum CodingKeys : String, CodingKey { case url = "photo_604" } init(from decoder: Decoder) throws { let values = try decoder.container(keyedBy: CodingKeys.self) url = try values.decode(String.self, forKey: .url) filename = NSString(string: url).lastPathComponent as String } }
Ответ сервера VK имеет следующую структуру:
{ "response": { "count": 12, "items": [...] } }
Интересующая нас информация лежит в items. Чтобы с такой структурой было удобнее работать, сделаем вспомогательную модель VKResponse.
struct VKResponse<T: Decodable>: Decodable { let items: [T]? private enum RootKeys : String, CodingKey { case response } private enum CodingKeys : String, CodingKey { case items } init(from decoder: Decoder) throws { let values = try decoder.container(keyedBy: RootKeys.self) let responseValues = try values.nestedContainer(keyedBy: CodingKeys.self, forKey: .response) items = try responseValues.decodeIfPresent([T].self, forKey: .items) } }
Очевидно, что получать данные из сети не входит в обязанности класса, отвечающего за построение файловой системы, поэтому мы вынесем эту функциональность в отдельный класс, который мы назовем VKService (в боевом проекте его тоже следовало бы разбить на несколько слоев, но здесь мы не будем усложнять). Коротко опишу основные моменты: методы fetchPhotos и fetchAlbums делают GET-запрос при помощи URLSession
к API vk.com и парсят полученный JSON при помощи JSONDecoder
. Остальные методы предназначены просто для удобного получения соответствующих сущностей (фото из альбома, альбом по пути в файловой системе и прочее). Так как он имеет мало отношения к нашей сегодняшней теме, я не буду останавливаться на нем подробно, ты сможешь его найти в исходниках проекта.
Алгоритм отображения контента будет следующий. Проверяем, находимся ли мы в корневой директории, если да, то возвращаем список альбомов; иначе предполагаем, что мы вошли в альбом, пытаемся получить его и отобразить список фотографий.
override func contentsOfDirectory(atPath path: String) throws → [Any] { if path == "/" { requestAlbums() return vkService.albums.map{ $0.title } } guard let album = vkService.getAlbum(forPath: path) else { return [] } requestPhotos(forAlbum: album) return album.photos.map{ $0.filename } }
Методы requestAlbums()
и requestPhotos()
нам нужны, чтобы запросить у нашего сервиса соответствующий контент.
private func requestAlbums() { if vkService.albums.count != 0 { return } vkService.fetchAlbums { (_, error) in if let error = error { print("ERROR: " + error.localizedDescription) } NSWorkspace.shared.noteFileSystemChanged("/Volumes/hello/") } } private func requestPhotos(forAlbum album: Album) { if album.photos.count != 0 { return } vkService.fetchPhotos(forAlbum: album) { (photos, error) in if let error = error { print("ERROR: " + error.localizedDescription) } NSWorkspace.shared.noteFileSystemChanged("/Volumes/hello/" + album.title) } }
Обрати внимание на вызов метода NSWorkspace.shared.noteFileSystemChanged
. Существует проблема с тем, что SDK FUSE for macOS ожидает данные синхронно, соответственно, нам нужно будет как-то обновить список файлов, после того как он вернется с сервера. Для этого мы и вызываем упомянутый метод: он сообщит файловой системе, что нужно обновить контент по переданному пути, и метод contentsOfDirectory
будет вызван еще раз.
Для простоты здесь данные запрашиваются только один раз, но в боевом решении, конечно, должен быть некий кеширующий сервис, который будет возвращать актуальные данные, при необходимости обновлять их.
Альтернативой (а иногда единственным выходом) может быть решение обращаться к серверу синхронно. Так я делаю в методе получения самой фотографии.
override func contents(atPath path: String) → Data? { return vkService.getPhotoData(forPath: path) }
Метод getPhotoData
, принадлежащий классу VKService
, получает данные синхронно, используя метод sendSynchronousRequest
класса NSURLConnection
. Синхронное получение данных будет выглядеть следующим образом:
private func fetchPhoto(urlPath: String) → Data? { guard let url = URL(string: urlPath) else { return nil } let request = URLRequest(url: url) var response: URLResponse? do { return try NSURLConnection.sendSynchronousRequest(request, returning: &response) } catch { print("ERROR: (error.localizedDescription).") } return nil }
Этот метод в настоящее время помечен Apple как устаревший, поэтому альтернативно можно использовать DispatchSemaphore
в комплекте с URLSessionDataTask
.
У вдумчивого читателя к этому моменту должен был возникнуть вопрос: а как файловая система определяет, показать файл или папку? Для этого необходимо переопределить еще один метод, attributesOfItem
.
override func attributesOfItem(atPath path: String!, userData: Any!) throws → [AnyHashable : Any] { var attributes: [FileAttributeKey : Any] = [:] if path == "/" { return attributes } let album = vkService.getAlbum(forPath: path) if (album != nil) { attributes[FileAttributeKey.type] = FileAttributeType.typeDirectory } else { attributes[FileAttributeKey.type] = FileAttributeType.typeRegular } return attributes }
Здесь мы определяем, какие атрибуты выставить для файла по заданному пути. Я проверяю, если мы можем получить альбом для этого пути, то выставляем тип «Директория», иначе — «Файл». Для корневой директории нам не требуется возвращать никаких атрибутов.
В сегодняшнем материале мы рассматривали примеры исключительно read-only-систем, но нужно понимать, что реализовать запись и удаление файлов тоже не сложно: достаточно аналогичным способом переопределить соответствующие методы, с полным перечнем которых ты можешь ознакомиться в заголовочном файле OSXFUSE/OSXFUSE.h
. Еще хотелось бы обратить внимание, что метод contents(atPath:)
не единственный способ вернуть содержимое файла, для более сложных случаев можно реализовать полный цикл open/read/release, и авторы библиотеки рекомендуют именно этот способ, как более производительный.
Также за кадром осталось то, что FUSE for macOS умеет генерировать события для Notification Center, на которые можно подписаться (например, открывать Finder после того, как система примонтировалась). Пример использования ты также найдешь в исходниках.
Какая особенность FUSE вызывает проблемы согласования при управлении памятью?
Загрузка ...
C одной стороны, FUSE for macOS — самый простой способ реализовать собственную файловую систему для macOS, который предоставляет верхнеуровневый и довольно удобный интерфейс для описания ее поведения. На другой чаше весов мы имеем следующие недостатки: очень слабая документация — практически единственным источником знаний по этому проекту служат примеры авторов на GitHub; низкий уровень активности в проекте — хотя авторы в рассылке утверждали, что не забросили проект, дождаться ответов на свои вопросы в Issues на GitHub почти нереально. Еще хотелось бы отметить плохо проработанную систему ошибок: если ты сделал что-то неправильно, скорее всего, тебе не прилетит ошибка, а просто перестанут отображаться файлы без каких-либо логов в консоли и прочего. Резюмируя вышесказанное: прежде чем использовать этот проект в продакшене, ты должен быть морально готов самостоятельно разбираться с большинством проблем и закладывать в оценку проекта возможные риски.
Читайте также
Последние новости