Use APFS cloning for file copies on macOS

Add Zotero.File.copyFile(), which uses clonefile() on APFS with a
fallback to IOUtils.copy(), and use it for all significant file copies.
APFS is detected and cached for each parent folder via
Zotero.File.isAPFS(), which uses statfs().

On APFS, all database backups now use the offline (close/clone/reopen)
path instead of the SQLite online backup API. Cloning is nearly
instant, and backup files share disk blocks via copy-on-write, saving
potentially gigabytes of space.

Closes #5330
This commit is contained in:
Dan Stillman 2026-04-03 14:15:51 -04:00
parent b11d776b36
commit 67288047f3
6 changed files with 157 additions and 7 deletions

View file

@ -348,7 +348,7 @@ Zotero.Attachments = new function () {
await OS.File.move(file.path, newPath);
}
else {
await OS.File.copy(file.path, newPath);
await Zotero.File.copyFile(file.path, newPath);
}
}
// Copy entire parent directory (for HTML snapshots)

View file

@ -1043,13 +1043,19 @@ Zotero.DBConnection.prototype.backUpDatabase = async function ({ force, suffix,
return this._offlineBackupPromise;
}
// On APFS, use cloning for all backups -- it's nearly instant and backup files share
// disk blocks via copy-on-write, saving potentially gigabytes of space
if (online && Zotero.File.isAPFS(this._dbPath)) {
online = false;
}
var resolveOfflineBackupPromise;
var success = false;
if (online) {
this._onlineBackupInProgress = true;
}
// For offline backups, start a promise that will be resolved when the backup finishes
else if (!online) {
else {
this._offlineBackupPromise = new Promise(function () {
resolveOfflineBackupPromise = arguments[0];
});
@ -1114,7 +1120,7 @@ Zotero.DBConnection.prototype.backUpDatabase = async function ({ force, suffix,
else {
try {
await this.closeDatabase();
await IOUtils.copy(this._dbPath, tmpFile);
await Zotero.File.copyFile(this._dbPath, tmpFile);
}
catch (e) {
Zotero.logError(e);
@ -1453,7 +1459,7 @@ Zotero.DBConnection.prototype._handleCorruptionMarker = async function () {
// Copy backup file to main DB file
this._debug("Restoring database '" + this._dbName + "' from backup file", 1);
try {
await OS.File.copy(backupFile, file);
await Zotero.File.copyFile(backupFile, file);
}
catch (e) {
// TODO: deal with low disk space

View file

@ -1086,11 +1086,108 @@ Zotero.File = new function () {
return this.iterateDirectory(source, function (entry) {
return entry.isDir
? this.copyDirectory(entry.path, OS.Path.join(target, entry.name))
: OS.File.copy(entry.path, OS.Path.join(target, entry.name));
: this.copyFile(entry.path, OS.Path.join(target, entry.name));
}.bind(this))
};
var _isAPFSCache = {};
/**
* Check if a path is on an APFS volume
*
* @param {String} path
* @return {Boolean}
*/
this.isAPFS = function (path) {
if (!Zotero.isMac) return false;
let dir = PathUtils.parent(path);
if (dir in _isAPFSCache) {
return _isAPFSCache[dir];
}
let result = false;
try {
let { ctypes } = ChromeUtils.importESModule(
"resource://gre/modules/ctypes.sys.mjs"
);
// struct statfs -- f_fstypename is a char[16] at byte offset 72
const STATFS_SIZE = 2168;
const FSTYPENAME_OFFSET = 72;
const FSTYPENAME_LEN = 16;
let buf = new ctypes.ArrayType(ctypes.uint8_t, STATFS_SIZE)();
let lib = ctypes.open("/usr/lib/libSystem.B.dylib");
try {
let statfs = lib.declare(
"statfs",
ctypes.default_abi,
ctypes.int,
ctypes.char.ptr,
ctypes.voidptr_t
);
if (statfs(dir, buf.address()) === 0) {
let typeName = '';
for (let i = FSTYPENAME_OFFSET; i < FSTYPENAME_OFFSET + FSTYPENAME_LEN; i++) {
if (buf[i] === 0) break;
typeName += String.fromCharCode(buf[i]);
}
result = typeName === 'apfs';
}
}
finally {
lib.close();
}
}
catch (e) {
Zotero.warn("Failed to check filesystem type: " + e);
}
_isAPFSCache[dir] = result;
return result;
};
/**
* Copy a file, using APFS cloning on macOS when available and falling back to a regular
* copy otherwise
*
* @param {String} source
* @param {String} target
*/
this.copyFile = async function (source, target) {
if (this.isAPFS(source)) {
try {
let { ctypes } = ChromeUtils.importESModule(
"resource://gre/modules/ctypes.sys.mjs"
);
let lib = ctypes.open("/usr/lib/libSystem.B.dylib");
try {
let clonefile = lib.declare(
"clonefile",
ctypes.default_abi,
ctypes.int,
ctypes.char.ptr, // src
ctypes.char.ptr, // dst
ctypes.uint32_t // flags
);
let result = clonefile(source, target, 0);
if (result === 0) {
return;
}
}
finally {
lib.close();
}
}
catch (e) {
Zotero.warn("clonefile() failed -- falling back to regular copy: " + e);
}
}
await IOUtils.copy(source, target);
};
this.createDirectoryIfMissing = function (dir) {
dir = this.pathToFile(dir);
if (!dir.exists() || !dir.isDirectory()) {

View file

@ -222,7 +222,7 @@ class PDFWorker {
return 0;
}
if (!annotations.length) {
await OS.File.copy(attachmentPath, path);
await Zotero.File.copyFile(attachmentPath, path);
return 0;
}
let buf = await IOUtils.read(attachmentPath);

View file

@ -431,6 +431,8 @@ describe("Zotero.DB", function () {
});
it("should perform an offline backup if an online backup is already in progress", async function () {
// On APFS, online backups use cloning (offline path), so this doesn't apply
if (Zotero.File.isAPFS(Zotero.DB.path)) this.skip();
var promise = Zotero.DB.backUpDatabase({ suffix: 'test', online: true });
var result = await Zotero.DB.backUpDatabase({ suffix: 'test2' });
// The online backup fails
@ -439,8 +441,10 @@ describe("Zotero.DB", function () {
assert.isTrue(result);
assert.isTrue(await IOUtils.exists(bakFile2));
});
it("shouldn't perform an online backup if one is already in progress", async function () {
// On APFS, online backups use cloning (offline path), so this doesn't apply
if (Zotero.File.isAPFS(Zotero.DB.path)) this.skip();
var promise = Zotero.DB.backUpDatabase({ suffix: 'test', online: true });
var result = await Zotero.DB.backUpDatabase({ suffix: 'test2', online: true });
await promise;

View file

@ -220,6 +220,49 @@ describe("Zotero.File", function () {
});
describe("#isAPFS()", function () {
it("should return true on macOS for the temp directory", function () {
if (!Zotero.isMac) this.skip();
assert.isTrue(Zotero.File.isAPFS(Zotero.getTempDirectory().path));
});
it("should return false on non-Mac platforms", function () {
if (Zotero.isMac) this.skip();
assert.isFalse(Zotero.File.isAPFS(Zotero.getTempDirectory().path));
});
});
describe("#copyFile()", function () {
it("should copy a file", async function () {
let tmpDir = await getTempDirectory();
let source = OS.Path.join(tmpDir, "source.txt");
let target = OS.Path.join(tmpDir, "target.txt");
await Zotero.File.putContentsAsync(source, "Hello world");
await Zotero.File.copyFile(source, target);
assert.isTrue(await OS.File.exists(target));
assert.equal(await Zotero.File.getContentsAsync(target), "Hello world");
});
it("should copy a file to a different directory", async function () {
let tmpDir = await getTempDirectory();
let subDir = OS.Path.join(tmpDir, "sub");
await OS.File.makeDir(subDir);
let source = OS.Path.join(tmpDir, "source.txt");
let target = OS.Path.join(subDir, "target.txt");
await Zotero.File.putContentsAsync(source, "Hello world");
await Zotero.File.copyFile(source, target);
assert.isTrue(await OS.File.exists(target));
assert.equal(await Zotero.File.getContentsAsync(target), "Hello world");
// Source should still exist
assert.isTrue(await OS.File.exists(source));
});
});
describe("#copyDirectory()", function () {
it("should copy all files within a directory", async function () {
var tmpDir = Zotero.getTempDirectory().path;