89986d5d17
- 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>
155 lines
5.8 KiB
Swift
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 ?? ""
|
|
}
|
|
}
|
|
}
|