Skip to content

Add hardlink and symlink support #92

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 6 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,11 @@ type ManagedFetchResult = {
- App groups are used on iOS/MacOS for storing content, which is shared between apps.
- This is e.g. useful for sharing data between your iOS/MacOS app and a widget or a watch app.

`FileSystem.hardlink(source: string, target: string): Promise<void>`

- Create a hard link at target pointing to source.
- Note: On Android, creating hardlinks requires root access.

`FilesSystem.hash(path: string, algorithm: 'MD5' | 'SHA-1' | 'SHA-224' | 'SHA-256' | 'SHA-384' | 'SHA-512'): Promise<string>`

- Hash the file content.
Expand Down Expand Up @@ -187,6 +192,10 @@ type FileStat = {

- Read metadata of all files in a directory.

`FileSystem.symlink(source: string, target: string): Promise<void>`

- Create a symbolic link at target pointing to source.

`FileSystem.unlink(path: string): Promise<void>`

- Delete a file.
Expand Down
69 changes: 69 additions & 0 deletions android/src/main/java/com/alpha0010/fs/FileAccessModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import java.io.OutputStream
import java.lang.ref.WeakReference
import java.security.MessageDigest
import java.util.zip.ZipInputStream
import java.nio.file.Files

class FileAccessModule internal constructor(context: ReactApplicationContext) :
FileAccessSpec(context) {
Expand Down Expand Up @@ -275,6 +276,40 @@ class FileAccessModule internal constructor(context: ReactApplicationContext) :
promise.reject("ERR", "App group unavailable on Android.")
}

@ReactMethod
override fun hardlink(source: String, target: String, promise: Promise) {
ioScope.launch {
try {
if (source.isContentUri() || target.isContentUri()) {
promise.reject("ERR", "Hard links are not supported for content URIs")
return@launch
}

val sourceFile = parsePathToFile(source)
val targetFile = parsePathToFile(target)

if (!sourceFile.exists()) {
promise.reject("ENOENT", "Source file '$source' does not exist")
return@launch
}

if (targetFile.exists()) {
promise.reject("EEXIST", "Target file '$target' already exists")
return@launch
}

try {
Files.createLink(targetFile.toPath(), sourceFile.toPath())
promise.resolve(null)
} catch (e: IOException) {
promise.reject("ERR", "Failed to create hard link: ${e.message}")
}
} catch (e: Throwable) {
promise.reject(e)
}
}
}

@ReactMethod
override fun hash(path: String, algorithm: String, promise: Promise) {
ioScope.launch {
Expand Down Expand Up @@ -455,6 +490,40 @@ class FileAccessModule internal constructor(context: ReactApplicationContext) :
}
}

@ReactMethod
override fun symlink(source: String, target: String, promise: Promise) {
ioScope.launch {
try {
if (source.isContentUri() || target.isContentUri()) {
promise.reject("ERR", "Symbolic links are not supported for content URIs")
return@launch
}

val sourceFile = parsePathToFile(source)
val targetFile = parsePathToFile(target)

if (!sourceFile.exists()) {
promise.reject("ENOENT", "Source file '$source' does not exist")
return@launch
}

if (targetFile.exists()) {
promise.reject("EEXIST", "Target file '$target' already exists")
return@launch
}

try {
Files.createSymbolicLink(targetFile.toPath(), sourceFile.toPath())
promise.resolve(null)
} catch (e: IOException) {
promise.reject("ERR", "Failed to create symbolic link: ${e.message}")
}
} catch (e: Throwable) {
promise.reject(e)
}
}
}

@ReactMethod
override fun unlink(path: String, promise: Promise) {
ioScope.launch {
Expand Down
2 changes: 2 additions & 0 deletions android/src/oldarch/FileAccessSpec.kt
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ abstract class FileAccessSpec internal constructor(context: ReactApplicationCont
abstract fun exists(path: String, promise: Promise)
abstract fun fetch(requestId: Double, resource: String, init: ReadableMap)
abstract fun getAppGroupDir(groupName: String, promise: Promise)
abstract fun hardlink(source: String, target: String, promise: Promise)
abstract fun hash(path: String, algorithm: String, promise: Promise)
abstract fun isDir(path: String, promise: Promise)
abstract fun ls(path: String, promise: Promise)
Expand All @@ -33,6 +34,7 @@ abstract class FileAccessSpec internal constructor(context: ReactApplicationCont
abstract fun readFileChunk(path: String, offset: Double, length: Double, encoding: String, promise: Promise)
abstract fun stat(path: String, promise: Promise)
abstract fun statDir(path: String, promise: Promise)
abstract fun symlink(source: String, target: String, promise: Promise)
abstract fun unlink(path: String, promise: Promise)
abstract fun unzip(source: String, target: String, promise: Promise)
abstract fun writeFile(path: String, data: String, encoding: String, promise: Promise)
Expand Down
18 changes: 18 additions & 0 deletions ios/FileAccess.mm
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ - (void)df:(RCTPromiseResolveBlock _Nonnull)resolve withRejecter:(RCTPromiseReje
- (void)exists:(NSString * _Nonnull)path withResolver:(RCTPromiseResolveBlock _Nonnull)resolve withRejecter:(RCTPromiseRejectBlock _Nonnull)reject;
- (void)fetch:(NSNumber * _Nonnull)requestId withResource:(NSString * _Nonnull)resource withConfig:(NSDictionary * _Nonnull)config withEmitter:(RCTEventEmitter * _Nonnull)emitter;
- (void)getAppGroupDir:(NSString * _Nonnull)groupName withResolver:(RCTPromiseResolveBlock _Nonnull)resolve withRejecter:(RCTPromiseRejectBlock _Nonnull)reject;
- (void)hardlink:(NSString * _Nonnull)source withTarget:(NSString * _Nonnull)target withResolver:(RCTPromiseResolveBlock _Nonnull)resolve withRejecter:(RCTPromiseRejectBlock _Nonnull)reject;
- (void)hash:(NSString * _Nonnull)path withAlgorithm:(NSString * _Nonnull)algorithm withResolver:(RCTPromiseResolveBlock _Nonnull)resolve withRejecter:(RCTPromiseRejectBlock _Nonnull)reject;
- (void)isDir:(NSString * _Nonnull)path withResolver:(RCTPromiseResolveBlock _Nonnull)resolve withRejecter:(RCTPromiseRejectBlock _Nonnull)reject;
- (void)ls:(NSString * _Nonnull)path withResolver:(RCTPromiseResolveBlock _Nonnull)resolve withRejecter:(RCTPromiseRejectBlock _Nonnull)reject;
Expand All @@ -25,6 +26,7 @@ - (void)readFile:(NSString * _Nonnull)path withEncoding:(NSString * _Nonnull)enc
- (void)readFileChunk:(NSString * _Nonnull)path withOffset:(NSNumber * _Nonnull)offset withLength:(NSNumber * _Nonnull)length withEncoding:(NSString * _Nonnull)encoding withResolver:(RCTPromiseResolveBlock _Nonnull)resolve withRejecter:(RCTPromiseRejectBlock _Nonnull)reject;
- (void)stat:(NSString * _Nonnull)path withResolver:(RCTPromiseResolveBlock _Nonnull)resolve withRejecter:(RCTPromiseRejectBlock _Nonnull)reject;
- (void)statDir:(NSString * _Nonnull)path withResolver:(RCTPromiseResolveBlock _Nonnull)resolve withRejecter:(RCTPromiseRejectBlock _Nonnull)reject;
- (void)symlink:(NSString * _Nonnull)source withTarget:(NSString * _Nonnull)target withResolver:(RCTPromiseResolveBlock _Nonnull)resolve withRejecter:(RCTPromiseRejectBlock _Nonnull)reject;
- (void)unlink:(NSString * _Nonnull)path withResolver:(RCTPromiseResolveBlock _Nonnull)resolve withRejecter:(RCTPromiseRejectBlock _Nonnull)reject;
- (void)unzip:(NSString * _Nonnull)source withTarget:(NSString * _Nonnull)target withResolver:(RCTPromiseResolveBlock _Nonnull)resolve withRejecter:(RCTPromiseRejectBlock _Nonnull)reject;
- (void)writeFile:(NSString * _Nonnull)path withData:(NSString * _Nonnull)data withEncoding:(NSString * _Nonnull)encoding withResolver:(RCTPromiseResolveBlock _Nonnull)resolve withRejecter:(RCTPromiseRejectBlock _Nonnull)reject;
Expand Down Expand Up @@ -126,6 +128,14 @@ - (instancetype)init
[impl getAppGroupDir:groupName withResolver:resolve withRejecter:reject];
}

RCT_EXPORT_METHOD(hardlink:(NSString *)source
target:(NSString *)target
resolve:(RCTPromiseResolveBlock)resolve
reject:(RCTPromiseRejectBlock)reject)
{
[impl hardlink:source withTarget:target withResolver:resolve withRejecter:reject];
}

RCT_EXPORT_METHOD(hash:(NSString *)path
algorithm:(NSString *)algorithm
resolve:(RCTPromiseResolveBlock)resolve
Expand Down Expand Up @@ -198,6 +208,14 @@ - (instancetype)init
[impl statDir:path withResolver:resolve withRejecter:reject];
}

RCT_EXPORT_METHOD(symlink:(NSString *)source
target:(NSString *)target
resolve:(RCTPromiseResolveBlock)resolve
reject:(RCTPromiseRejectBlock)reject)
{
[impl symlink:source withTarget:target withResolver:resolve withRejecter:reject];
}

RCT_EXPORT_METHOD(unlink:(NSString *)path
resolve:(RCTPromiseResolveBlock)resolve
reject:(RCTPromiseRejectBlock)reject)
Expand Down
28 changes: 26 additions & 2 deletions ios/FileAccess.swift
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,18 @@ public class FileAccess : NSObject {
resolve(groupURL.path)
}

@objc(hardlink:withTarget:withResolver:withRejecter:)
public func hardlink(source: String, target: String, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) -> Void {
DispatchQueue.global().async {
do {
try FileManager.default.linkItem(atPath: source.path(), toPath: target.path())
resolve(nil)
} catch {
reject("ERR", "Failed to create hard link from '\(source)' to '\(target)'. \(error.localizedDescription)", error)
}
}
}

@objc(hash:withAlgorithm:withResolver:withRejecter:)
public func hash(path: String, algorithm: String, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) -> Void {
DispatchQueue.global().async {
Expand Down Expand Up @@ -280,10 +292,10 @@ public class FileAccess : NSObject {
defer {
fileHandle.closeFile()
}

fileHandle.seek(toFileOffset: UInt64(truncating: offset))
let binaryData = fileHandle.readData(ofLength: Int(truncating: length))

if encoding == "base64" {
resolve(binaryData.base64EncodedString())
} else {
Expand Down Expand Up @@ -324,6 +336,18 @@ public class FileAccess : NSObject {
}
}

@objc(symlink:withTarget:withResolver:withRejecter:)
public func symlink(source: String, target: String, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) -> Void {
DispatchQueue.global().async {
do {
try FileManager.default.createSymbolicLink(atPath: target.path(), withDestinationPath: source.path())
resolve(nil)
} catch {
reject("ERR", "Failed to create symbolic link from '\(source)' to '\(target)'. \(error.localizedDescription)", error)
}
}
}

@objc(unlink:withResolver:withRejecter:)
public func unlink(path: String, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) -> Void {
DispatchQueue.global().async {
Expand Down
22 changes: 22 additions & 0 deletions jest/react-native-file-access.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,17 @@ class FileSystemMock {
return `${Dirs.DocumentDir}/shared/AppGroup/${groupName}`;
});

/**
* Create a hard link.
*/
public hardlink = jest.fn(async (source: string, target: string) => {
const sourceData = this.filesystem.get(source);
if (!sourceData) {
throw new Error(`Source file ${source} not found`);
}
this.filesystem.set(target, sourceData);
});

/**
* Hash the file content.
*/
Expand Down Expand Up @@ -166,6 +177,17 @@ class FileSystemMock {
})
);

/**
* Create a symbolic link.
*/
public symlink = jest.fn(async (source: string, target: string) => {
const sourceData = this.filesystem.get(source);
if (!sourceData) {
throw new Error(`Source file ${source} not found`);
}
this.filesystem.set(target, sourceData);
});

/**
* Delete a file.
*/
Expand Down
2 changes: 2 additions & 0 deletions src/NativeFileAccess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ export interface Spec extends TurboModule {
MainBundleDir: string;
SDCardDir?: string;
};
hardlink(source: string, target: string): Promise<void>;
hash(path: string, algorithm: string): Promise<string>;
isDir(path: string): Promise<boolean>;
ls(path: string): Promise<string[]>;
Expand All @@ -79,6 +80,7 @@ export interface Spec extends TurboModule {
): Promise<string>;
stat(path: string): Promise<FileStat>;
statDir(path: string): Promise<FileStat[]>;
symlink(source: string, target: string): Promise<void>;
unlink(path: string): Promise<void>;
unzip(source: string, target: string): Promise<void>;
writeFile(path: string, data: string, encoding: string): Promise<void>;
Expand Down
18 changes: 18 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,15 @@ export const FileSystem = {
return FileAccessNative.getAppGroupDir(groupName);
},

/**
* Create a hard link.
*
* Creates a hard link at target pointing to source.
*/
hardlink(source: string, target: string) {
return FileAccessNative.hardlink(source, target);
},

/**
* Hash the file content.
*/
Expand Down Expand Up @@ -301,6 +310,15 @@ export const FileSystem = {
return FileAccessNative.statDir(path);
},

/**
* Create a symbolic link.
*
* Creates a symbolic link at target pointing to source.
*/
symlink(source: string, target: string) {
return FileAccessNative.symlink(source, target);
},

/**
* Delete a file.
*/
Expand Down