Files
lnreader/modules/native-file/ios/NativeFileModule.swift
T
masi 89986d5d17 iOS build for sideloading, with the open-lab plugin repository preconfigured
- Background tasks: a JS implementation of the native task module for iOS
  (NativeBackgroundTasksModule.ios.ts) with the Android module's contract:
  per-queue order, 3 concurrent chapter downloads, retries with a delay,
  pause/resume/cancel interruptions, persistence across launches. Tasks run
  while the app is open; periodic library updates and backups are not
  scheduled on iOS. The module is Android-only in autolinking now.
- Reader assets: WKWebView will not load file:// assets into a page whose base
  URL is the source's site, so on iOS the reader CSS, JS and font are inlined
  (scripts/embed-reader-assets.mjs, src/utils/readerAssets). Downloaded
  chapters get a file: base URL and file access on iOS. Android is unchanged.
- Native iOS: downloadFile implemented with URLSession (plugin installs and
  chapter images); volume-button setActive added as a no-op (it is called on
  every chapter).
- Share receiver stub and silent toasts on iOS; TrackSheet uses showToast.
- app.json: bundle id br.dev.marcospaulo.lnreader, http sources allowed, Files
  sharing, deployment target 16.4.
- Fresh installs start with the open-lab plugin repository.
- scripts/build-ios-unsigned.sh and docs/ios-sidestore.md.

Verified on Linux: tsc, eslint, all Jest suites (plus new iOS queue tests),
expo prebuild for iOS, autolinking, and iOS/Android release JS bundles.
Not yet built with Xcode or run on a device.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 20:12:31 -03:00

155 lines
5.8 KiB
Swift

import ExpoModulesCore
import Foundation
public class NativeFileModule: Module {
public func definition() -> ModuleDefinition {
Name("NativeFile")
Function("writeFile") { (path: String, content: String) in
try content.write(toFile: path, atomically: true, encoding: .utf8)
}
Function("readFile") { (path: String) in
try String(contentsOfFile: path, encoding: .utf8)
}
Function("copyFile") { (sourcePath: String, destPath: String) in
try FileManager.default.copyItem(atPath: sourcePath, toPath: destPath)
}
AsyncFunction("copyFileToDirectory") { (sourcePath: String, directoryUri: String, fileName: String, mimeType: String, replace: Bool) -> [String: Any] in
_ = mimeType
guard !fileName.isEmpty,
fileName != ".",
fileName != "..",
!fileName.contains("/"),
!fileName.contains("\\") else {
throw NSError(
domain: "NativeFile",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "Invalid destination file name"]
)
}
let fileManager = FileManager.default
let directoryURL: URL
if let parsedURL = URL(string: directoryUri), parsedURL.isFileURL {
directoryURL = parsedURL
} else {
directoryURL = URL(fileURLWithPath: directoryUri, isDirectory: true)
}
let sourceURL = URL(fileURLWithPath: sourcePath)
let destinationURL = directoryURL.appendingPathComponent(fileName)
let stagingURL = directoryURL.appendingPathComponent(".\(fileName).\(UUID().uuidString).tmp")
guard fileManager.fileExists(atPath: directoryURL.path) else {
throw NSError(
domain: "NativeFile",
code: 2,
userInfo: [NSLocalizedDescriptionKey: "Destination directory does not exist"]
)
}
if fileManager.fileExists(atPath: destinationURL.path) && !replace {
throw CocoaError(.fileWriteFileExists)
}
defer { try? fileManager.removeItem(at: stagingURL) }
try fileManager.copyItem(at: sourceURL, to: stagingURL)
let attributes = try fileManager.attributesOfItem(atPath: stagingURL.path)
let copiedSize = (attributes[.size] as? NSNumber)?.int64Value ?? 0
if fileManager.fileExists(atPath: destinationURL.path) {
_ = try fileManager.replaceItemAt(destinationURL, withItemAt: stagingURL)
} else {
try fileManager.moveItem(at: stagingURL, to: destinationURL)
}
return ["uri": destinationURL.absoluteString, "size": copiedSize]
}
Function("moveFile") { (sourcePath: String, destPath: String) in
try FileManager.default.moveItem(atPath: sourcePath, toPath: destPath)
}
Function("exists") { (filePath: String) in
FileManager.default.fileExists(atPath: filePath)
}
Function("mkdir") { (filePath: String) in
try FileManager.default.createDirectory(atPath: filePath, withIntermediateDirectories: true, attributes: nil)
}
Function("unlink") { (filePath: String) in
try FileManager.default.removeItem(atPath: filePath)
}
Function("readDir") { (dirPath: String) -> [[String: Any]] in
let contents = try FileManager.default.contentsOfDirectory(atPath: dirPath)
return contents.map { fileName in
let path = (dirPath as NSString).appendingPathComponent(fileName)
var isDirectory: ObjCBool = false
FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory)
return [
"name": fileName,
"path": path,
"isDirectory": isDirectory.boolValue
]
}
}
// As the Android module: GET, or POST with the body for any other method; the file
// is written only for a 2xx response. URLSession undoes gzip Content-Encoding itself.
AsyncFunction("downloadFile") { (url: String, destPath: String, method: String, headers: [String: String], body: String?, promise: Promise) in
guard let target = URL(string: url) else {
promise.reject("DOWNLOAD_FAILED", "Invalid URL: \(url)")
return
}
var request = URLRequest(url: target)
if method.lowercased() != "get", let body = body {
request.httpMethod = "POST"
request.httpBody = body.data(using: .utf8)
}
for (key, value) in headers {
request.setValue(value, forHTTPHeaderField: key)
}
URLSession.shared.downloadTask(with: request) { location, response, error in
if let error = error {
promise.reject("DOWNLOAD_FAILED", error.localizedDescription)
return
}
let status = (response as? HTTPURLResponse)?.statusCode ?? -1
guard (200..<300).contains(status), let location = location else {
promise.reject("DOWNLOAD_FAILED", "Failed to download: \(status)")
return
}
do {
let destination = URL(fileURLWithPath: destPath)
let files = FileManager.default
try files.createDirectory(at: destination.deletingLastPathComponent(), withIntermediateDirectories: true)
if files.fileExists(atPath: destPath) {
try files.removeItem(at: destination)
}
try files.moveItem(at: location, to: destination)
promise.resolve(nil)
} catch {
promise.reject("DOWNLOAD_FAILED", error.localizedDescription)
}
}.resume()
}
Constant("DocumentDirectoryPath") {
let paths = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true)
return paths.first ?? ""
}
Constant("ExternalDirectoryPath") {
let paths = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true)
return paths.first ?? ""
}
Constant("ExternalCachesDirectoryPath") {
let paths = NSSearchPathForDirectoriesInDomains(.cachesDirectory, .userDomainMask, true)
return paths.first ?? ""
}
}
}