Skip to content

Commit 3750b70

Browse files
authored
Merge pull request #92 from mjmasn/add-hardlink-and-symlink-support
Add hardlink and symlink support
2 parents 541ccdd + 72fd9c8 commit 3750b70

File tree

8 files changed

+166
-2
lines changed

8 files changed

+166
-2
lines changed

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,11 @@ type ManagedFetchResult = {
139139
- App groups are used on iOS/MacOS for storing content, which is shared between apps.
140140
- This is e.g. useful for sharing data between your iOS/MacOS app and a widget or a watch app.
141141

142+
`FileSystem.hardlink(source: string, target: string): Promise<void>`
143+
144+
- Create a hard link at target pointing to source.
145+
- Note: On Android, creating hardlinks requires root access.
146+
142147
`FilesSystem.hash(path: string, algorithm: 'MD5' | 'SHA-1' | 'SHA-224' | 'SHA-256' | 'SHA-384' | 'SHA-512'): Promise<string>`
143148

144149
- Hash the file content.
@@ -188,6 +193,10 @@ type FileStat = {
188193

189194
- Read metadata of all files in a directory.
190195

196+
`FileSystem.symlink(source: string, target: string): Promise<void>`
197+
198+
- Create a symbolic link at target pointing to source.
199+
191200
`FileSystem.unlink(path: string): Promise<void>`
192201

193202
- Delete a file.

android/src/main/java/com/alpha0010/fs/FileAccessModule.kt

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import java.io.OutputStream
2525
import java.lang.ref.WeakReference
2626
import java.security.MessageDigest
2727
import java.util.zip.ZipInputStream
28+
import java.nio.file.Files
2829

2930
class FileAccessModule internal constructor(context: ReactApplicationContext) :
3031
FileAccessSpec(context) {
@@ -275,6 +276,40 @@ class FileAccessModule internal constructor(context: ReactApplicationContext) :
275276
promise.reject("ERR", "App group unavailable on Android.")
276277
}
277278

279+
@ReactMethod
280+
override fun hardlink(source: String, target: String, promise: Promise) {
281+
ioScope.launch {
282+
try {
283+
if (source.isContentUri() || target.isContentUri()) {
284+
promise.reject("ERR", "Hard links are not supported for content URIs")
285+
return@launch
286+
}
287+
288+
val sourceFile = parsePathToFile(source)
289+
val targetFile = parsePathToFile(target)
290+
291+
if (!sourceFile.exists()) {
292+
promise.reject("ENOENT", "Source file '$source' does not exist")
293+
return@launch
294+
}
295+
296+
if (targetFile.exists()) {
297+
promise.reject("EEXIST", "Target file '$target' already exists")
298+
return@launch
299+
}
300+
301+
try {
302+
Files.createLink(targetFile.toPath(), sourceFile.toPath())
303+
promise.resolve(null)
304+
} catch (e: IOException) {
305+
promise.reject("ERR", "Failed to create hard link: ${e.message}")
306+
}
307+
} catch (e: Throwable) {
308+
promise.reject(e)
309+
}
310+
}
311+
}
312+
278313
@ReactMethod
279314
override fun hash(path: String, algorithm: String, promise: Promise) {
280315
ioScope.launch {
@@ -455,6 +490,40 @@ class FileAccessModule internal constructor(context: ReactApplicationContext) :
455490
}
456491
}
457492

493+
@ReactMethod
494+
override fun symlink(source: String, target: String, promise: Promise) {
495+
ioScope.launch {
496+
try {
497+
if (source.isContentUri() || target.isContentUri()) {
498+
promise.reject("ERR", "Symbolic links are not supported for content URIs")
499+
return@launch
500+
}
501+
502+
val sourceFile = parsePathToFile(source)
503+
val targetFile = parsePathToFile(target)
504+
505+
if (!sourceFile.exists()) {
506+
promise.reject("ENOENT", "Source file '$source' does not exist")
507+
return@launch
508+
}
509+
510+
if (targetFile.exists()) {
511+
promise.reject("EEXIST", "Target file '$target' already exists")
512+
return@launch
513+
}
514+
515+
try {
516+
Files.createSymbolicLink(targetFile.toPath(), sourceFile.toPath())
517+
promise.resolve(null)
518+
} catch (e: IOException) {
519+
promise.reject("ERR", "Failed to create symbolic link: ${e.message}")
520+
}
521+
} catch (e: Throwable) {
522+
promise.reject(e)
523+
}
524+
}
525+
}
526+
458527
@ReactMethod
459528
override fun unlink(path: String, promise: Promise) {
460529
ioScope.launch {

android/src/oldarch/FileAccessSpec.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ abstract class FileAccessSpec internal constructor(context: ReactApplicationCont
2424
abstract fun exists(path: String, promise: Promise)
2525
abstract fun fetch(requestId: Double, resource: String, init: ReadableMap)
2626
abstract fun getAppGroupDir(groupName: String, promise: Promise)
27+
abstract fun hardlink(source: String, target: String, promise: Promise)
2728
abstract fun hash(path: String, algorithm: String, promise: Promise)
2829
abstract fun isDir(path: String, promise: Promise)
2930
abstract fun ls(path: String, promise: Promise)
@@ -33,6 +34,7 @@ abstract class FileAccessSpec internal constructor(context: ReactApplicationCont
3334
abstract fun readFileChunk(path: String, offset: Double, length: Double, encoding: String, promise: Promise)
3435
abstract fun stat(path: String, promise: Promise)
3536
abstract fun statDir(path: String, promise: Promise)
37+
abstract fun symlink(source: String, target: String, promise: Promise)
3638
abstract fun unlink(path: String, promise: Promise)
3739
abstract fun unzip(source: String, target: String, promise: Promise)
3840
abstract fun writeFile(path: String, data: String, encoding: String, promise: Promise)

ios/FileAccess.mm

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ - (void)df:(RCTPromiseResolveBlock _Nonnull)resolve withRejecter:(RCTPromiseReje
1616
- (void)exists:(NSString * _Nonnull)path withResolver:(RCTPromiseResolveBlock _Nonnull)resolve withRejecter:(RCTPromiseRejectBlock _Nonnull)reject;
1717
- (void)fetch:(NSNumber * _Nonnull)requestId withResource:(NSString * _Nonnull)resource withConfig:(NSDictionary * _Nonnull)config withEmitter:(RCTEventEmitter * _Nonnull)emitter;
1818
- (void)getAppGroupDir:(NSString * _Nonnull)groupName withResolver:(RCTPromiseResolveBlock _Nonnull)resolve withRejecter:(RCTPromiseRejectBlock _Nonnull)reject;
19+
- (void)hardlink:(NSString * _Nonnull)source withTarget:(NSString * _Nonnull)target withResolver:(RCTPromiseResolveBlock _Nonnull)resolve withRejecter:(RCTPromiseRejectBlock _Nonnull)reject;
1920
- (void)hash:(NSString * _Nonnull)path withAlgorithm:(NSString * _Nonnull)algorithm withResolver:(RCTPromiseResolveBlock _Nonnull)resolve withRejecter:(RCTPromiseRejectBlock _Nonnull)reject;
2021
- (void)isDir:(NSString * _Nonnull)path withResolver:(RCTPromiseResolveBlock _Nonnull)resolve withRejecter:(RCTPromiseRejectBlock _Nonnull)reject;
2122
- (void)ls:(NSString * _Nonnull)path withResolver:(RCTPromiseResolveBlock _Nonnull)resolve withRejecter:(RCTPromiseRejectBlock _Nonnull)reject;
@@ -25,6 +26,7 @@ - (void)readFile:(NSString * _Nonnull)path withEncoding:(NSString * _Nonnull)enc
2526
- (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;
2627
- (void)stat:(NSString * _Nonnull)path withResolver:(RCTPromiseResolveBlock _Nonnull)resolve withRejecter:(RCTPromiseRejectBlock _Nonnull)reject;
2728
- (void)statDir:(NSString * _Nonnull)path withResolver:(RCTPromiseResolveBlock _Nonnull)resolve withRejecter:(RCTPromiseRejectBlock _Nonnull)reject;
29+
- (void)symlink:(NSString * _Nonnull)source withTarget:(NSString * _Nonnull)target withResolver:(RCTPromiseResolveBlock _Nonnull)resolve withRejecter:(RCTPromiseRejectBlock _Nonnull)reject;
2830
- (void)unlink:(NSString * _Nonnull)path withResolver:(RCTPromiseResolveBlock _Nonnull)resolve withRejecter:(RCTPromiseRejectBlock _Nonnull)reject;
2931
- (void)unzip:(NSString * _Nonnull)source withTarget:(NSString * _Nonnull)target withResolver:(RCTPromiseResolveBlock _Nonnull)resolve withRejecter:(RCTPromiseRejectBlock _Nonnull)reject;
3032
- (void)writeFile:(NSString * _Nonnull)path withData:(NSString * _Nonnull)data withEncoding:(NSString * _Nonnull)encoding withResolver:(RCTPromiseResolveBlock _Nonnull)resolve withRejecter:(RCTPromiseRejectBlock _Nonnull)reject;
@@ -126,6 +128,14 @@ - (instancetype)init
126128
[impl getAppGroupDir:groupName withResolver:resolve withRejecter:reject];
127129
}
128130

131+
RCT_EXPORT_METHOD(hardlink:(NSString *)source
132+
target:(NSString *)target
133+
resolve:(RCTPromiseResolveBlock)resolve
134+
reject:(RCTPromiseRejectBlock)reject)
135+
{
136+
[impl hardlink:source withTarget:target withResolver:resolve withRejecter:reject];
137+
}
138+
129139
RCT_EXPORT_METHOD(hash:(NSString *)path
130140
algorithm:(NSString *)algorithm
131141
resolve:(RCTPromiseResolveBlock)resolve
@@ -198,6 +208,14 @@ - (instancetype)init
198208
[impl statDir:path withResolver:resolve withRejecter:reject];
199209
}
200210

211+
RCT_EXPORT_METHOD(symlink:(NSString *)source
212+
target:(NSString *)target
213+
resolve:(RCTPromiseResolveBlock)resolve
214+
reject:(RCTPromiseRejectBlock)reject)
215+
{
216+
[impl symlink:source withTarget:target withResolver:resolve withRejecter:reject];
217+
}
218+
201219
RCT_EXPORT_METHOD(unlink:(NSString *)path
202220
resolve:(RCTPromiseResolveBlock)resolve
203221
reject:(RCTPromiseRejectBlock)reject)

ios/FileAccess.swift

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,18 @@ public class FileAccess : NSObject {
172172
resolve(groupURL.path)
173173
}
174174

175+
@objc(hardlink:withTarget:withResolver:withRejecter:)
176+
public func hardlink(source: String, target: String, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) -> Void {
177+
DispatchQueue.global().async {
178+
do {
179+
try FileManager.default.linkItem(atPath: source.path(), toPath: target.path())
180+
resolve(nil)
181+
} catch {
182+
reject("ERR", "Failed to create hard link from '\(source)' to '\(target)'. \(error.localizedDescription)", error)
183+
}
184+
}
185+
}
186+
175187
@objc(hash:withAlgorithm:withResolver:withRejecter:)
176188
public func hash(path: String, algorithm: String, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) -> Void {
177189
DispatchQueue.global().async {
@@ -280,10 +292,10 @@ public class FileAccess : NSObject {
280292
defer {
281293
fileHandle.closeFile()
282294
}
283-
295+
284296
fileHandle.seek(toFileOffset: UInt64(truncating: offset))
285297
let binaryData = fileHandle.readData(ofLength: Int(truncating: length))
286-
298+
287299
if encoding == "base64" {
288300
resolve(binaryData.base64EncodedString())
289301
} else {
@@ -324,6 +336,18 @@ public class FileAccess : NSObject {
324336
}
325337
}
326338

339+
@objc(symlink:withTarget:withResolver:withRejecter:)
340+
public func symlink(source: String, target: String, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) -> Void {
341+
DispatchQueue.global().async {
342+
do {
343+
try FileManager.default.createSymbolicLink(atPath: target.path(), withDestinationPath: source.path())
344+
resolve(nil)
345+
} catch {
346+
reject("ERR", "Failed to create symbolic link from '\(source)' to '\(target)'. \(error.localizedDescription)", error)
347+
}
348+
}
349+
}
350+
327351
@objc(unlink:withResolver:withRejecter:)
328352
public func unlink(path: String, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) -> Void {
329353
DispatchQueue.global().async {

jest/react-native-file-access.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,17 @@ class FileSystemMock {
121121
return `${Dirs.DocumentDir}/shared/AppGroup/${groupName}`;
122122
});
123123

124+
/**
125+
* Create a hard link.
126+
*/
127+
public hardlink = jest.fn(async (source: string, target: string) => {
128+
const sourceData = this.filesystem.get(source);
129+
if (!sourceData) {
130+
throw new Error(`Source file ${source} not found`);
131+
}
132+
this.filesystem.set(target, sourceData);
133+
});
134+
124135
/**
125136
* Hash the file content.
126137
*/
@@ -167,6 +178,17 @@ class FileSystemMock {
167178
})
168179
);
169180

181+
/**
182+
* Create a symbolic link.
183+
*/
184+
public symlink = jest.fn(async (source: string, target: string) => {
185+
const sourceData = this.filesystem.get(source);
186+
if (!sourceData) {
187+
throw new Error(`Source file ${source} not found`);
188+
}
189+
this.filesystem.set(target, sourceData);
190+
});
191+
170192
/**
171193
* Delete a file.
172194
*/

src/NativeFileAccess.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ export interface Spec extends TurboModule {
6565
MainBundleDir: string;
6666
SDCardDir?: string;
6767
};
68+
hardlink(source: string, target: string): Promise<void>;
6869
hash(path: string, algorithm: string): Promise<string>;
6970
isDir(path: string): Promise<boolean>;
7071
ls(path: string): Promise<string[]>;
@@ -79,6 +80,7 @@ export interface Spec extends TurboModule {
7980
): Promise<string>;
8081
stat(path: string): Promise<FileStat>;
8182
statDir(path: string): Promise<FileStat[]>;
83+
symlink(source: string, target: string): Promise<void>;
8284
unlink(path: string): Promise<void>;
8385
unzip(source: string, target: string): Promise<void>;
8486
writeFile(path: string, data: string, encoding: string): Promise<void>;

src/index.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,15 @@ export const FileSystem = {
236236
return FileAccessNative.getAppGroupDir(groupName);
237237
},
238238

239+
/**
240+
* Create a hard link.
241+
*
242+
* Creates a hard link at target pointing to source.
243+
*/
244+
hardlink(source: string, target: string) {
245+
return FileAccessNative.hardlink(source, target);
246+
},
247+
239248
/**
240249
* Hash the file content.
241250
*/
@@ -306,6 +315,15 @@ export const FileSystem = {
306315
return FileAccessNative.statDir(path);
307316
},
308317

318+
/**
319+
* Create a symbolic link.
320+
*
321+
* Creates a symbolic link at target pointing to source.
322+
*/
323+
symlink(source: string, target: string) {
324+
return FileAccessNative.symlink(source, target);
325+
},
326+
309327
/**
310328
* Delete a file.
311329
*/

0 commit comments

Comments
 (0)