Skip to content
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

feat: fix missing createWritable in safari #62

Merged
merged 3 commits into from
Jul 19, 2023
Merged
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
2 changes: 1 addition & 1 deletion example/test.html
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@
<caption>Browser storage</caption>
<thead><tr>
<td>Test</td>
<td>Native</td>
<td>Native (Somewhat patched to fix buggy safari impl)</td>
<td>Sandbox</td>
<td>Memory</td>
<td>IndexedDB</td>
Expand Down
61 changes: 61 additions & 0 deletions src/FileSystemDirectoryHandle.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import FileSystemHandle from './FileSystemHandle.js'
import { errors } from './util.js'

const { GONE, MOD_ERR } = errors

const kAdapter = Symbol('adapter')

Expand Down Expand Up @@ -90,6 +93,7 @@ class FileSystemDirectoryHandle extends FileSystemHandle {

while (openSet.length) {
let { handle: current, path } = openSet.pop()

for await (const entry of current.values()) {
if (await entry.isSameEntry(possibleDescendant)) {
return [...path, entry.name]
Expand Down Expand Up @@ -132,5 +136,62 @@ Object.defineProperties(FileSystemDirectoryHandle.prototype, {
removeEntry: { enumerable: true }
})

if (globalThis.FileSystemDirectoryHandle) {
const proto = globalThis.FileSystemDirectoryHandle.prototype

proto.resolve = async function resolve (possibleDescendant) {
if (await possibleDescendant.isSameEntry(this)) {
return []
}

const openSet = [{ handle: this, path: [] }]

while (openSet.length) {
let { handle: current, path } = openSet.pop()

for await (const entry of current.values()) {
if (await entry.isSameEntry(possibleDescendant)) {
return [...path, entry.name]
}
if (entry.kind === 'directory') {
openSet.push({ handle: entry, path: [...path, entry.name] })
}
}
}

return null
}

// Safari allows us operate on deleted files,
// so we need to check if they still exist.
// Hope to remove this one day.
async function ensureDoActuallyStillExist (handle) {
const root = await navigator.storage.getDirectory()
const path = await root.resolve(handle)
if (path === null) { throw new DOMException(...GONE) }
}

const entries = proto.entries
proto.entries = async function * () {
await ensureDoActuallyStillExist(this)
yield * entries.call(this)
}
proto[Symbol.asyncIterator] = async function * () {
yield * this.entries()
}

const removeEntry = proto.removeEntry
proto.removeEntry = async function (name, options = {}) {
return removeEntry.call(this, name, options).catch(async err => {
const unknown = err instanceof DOMException && err.name === 'UnknownError'
if (unknown && !options.recursive) {
const empty = (await entries.call(this).next()).done
if (!empty) { throw new DOMException(...MOD_ERR) }
}
throw err
})
}
}

export default FileSystemDirectoryHandle
export { FileSystemDirectoryHandle }
181 changes: 181 additions & 0 deletions src/FileSystemFileHandle.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import FileSystemHandle from './FileSystemHandle.js'
import FileSystemWritableFileStream from './FileSystemWritableFileStream.js'
import { errors } from './util.js'

const { INVALID, SYNTAX, GONE } = errors

const kAdapter = Symbol('adapter')

Expand Down Expand Up @@ -43,5 +46,183 @@ Object.defineProperties(FileSystemFileHandle.prototype, {
getFile: { enumerable: true }
})

// Safari doesn't support async createWritable streams yet.
if (
globalThis.FileSystemFileHandle &&
!globalThis.FileSystemFileHandle.prototype.createWritable
) {
const wm = new WeakMap()

let workerUrl

// Worker code that should be inlined (can't use any external functions)
function code () {
let fileHandle, handle

onmessage = async evt => {
const port = evt.ports[0]
const cmd = evt.data
switch (cmd.type) {
case 'open':
const file = cmd.name

let dir = await navigator.storage.getDirectory()

for (const folder of cmd.path) {
dir = await dir.getDirectoryHandle(folder)
}

fileHandle = await dir.getFileHandle(file)
handle = await fileHandle.createSyncAccessHandle()
break
case 'write':
handle.write(cmd.data, { at: cmd.position })
handle.flush()
break
case 'truncate':
handle.truncate(cmd.size)
break
case 'abort':
case 'close':
handle.close()
break
}

port.postMessage(0)
}
}


globalThis.FileSystemFileHandle.prototype.createWritable = async function (options) {
// Safari only support writing data in a worker with sync access handle.
if (!workerUrl) {
const blob = new Blob([code.toString() + `;${code.name}();`], {
Copy link
Contributor

@SargisPlusPlus SargisPlusPlus Feb 8, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

another issue here...
not sure why but

code.toString() + `;${code.name}();`

produces:
function() {....}
which throws a syntax error because function doesnt have name.

To solve this, i change code to be arrow function:

  const code = () => { ... }

and then here I have:

      const stringCode = `(${code.toString()})()`;
      const blob = new Blob([stringCode], {

which produces:

(() => {...})()

And this works.

type: 'text/javascript'
})
workerUrl = URL.createObjectURL(blob)
}
const worker = new Worker(workerUrl, { type: 'module' })

let position = 0
const textEncoder = new TextEncoder()
let size = await this.getFile().then(file => file.size)

const send = message => new Promise((resolve, reject) => {
const mc = new MessageChannel()
mc.port1.onmessage = evt => {
if (evt.data instanceof Error) reject(evt.data)
else resolve(evt.data)
mc.port1.close()
mc.port2.close()
mc.port1.onmessage = null
}
worker.postMessage(message, [mc.port2])
})

// Safari also don't support transferable file system handles.
// So we need to pass the path to the worker. This is a bit hacky and ugly.
const root = await navigator.storage.getDirectory()
const parent = await wm.get(this)
const path = await parent.resolve(root)
Copy link
Contributor

@SargisPlusPlus SargisPlusPlus Feb 8, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jimmywarting path was null for me. I had to swap these two.

const path = await root.resolve(parent)


// Should likely never happen, but just in case...
if (path === null) throw new DOMException(...GONE)

let controller
await send({ type: 'open', path, name: this.name })

if (options?.keepExistingData === false) {
await send({ type: 'truncate', size: 0 })
size = 0
}

const ws = new FileSystemWritableFileStream({
start: ctrl => {
controller = ctrl
},
async write(chunk) {
const isPlainObject = chunk?.constructor === Object

if (isPlainObject) {
chunk = { ...chunk }
} else {
chunk = { type: 'write', data: chunk, position }
}

if (chunk.type === 'write') {
if (!('data' in chunk)) {
await send({ type: 'close' })
throw new DOMException(...SYNTAX('write requires a data argument'))
}

chunk.position ??= position

if (typeof chunk.data === 'string') {
chunk.data = textEncoder.encode(chunk.data)
}

else if (chunk.data instanceof ArrayBuffer) {
chunk.data = new Uint8Array(chunk.data)
}

else if (!(chunk.data instanceof Uint8Array) && ArrayBuffer.isView(chunk.data)) {
chunk.data = new Uint8Array(chunk.data.buffer, chunk.data.byteOffset, chunk.data.byteLength)
}

else if (!(chunk.data instanceof Uint8Array)) {
const ab = await new Response(chunk.data).arrayBuffer()
chunk.data = new Uint8Array(ab)
}

if (Number.isInteger(chunk.position) && chunk.position >= 0) {
position = chunk.position
}
position += chunk.data.byteLength
size += chunk.data.byteLength
} else if (chunk.type === 'seek') {
if (Number.isInteger(chunk.position) && chunk.position >= 0) {
if (size < chunk.position) {
throw new DOMException(...INVALID)
}
console.log('seeking', chunk)
position = chunk.position
return // Don't need to enqueue seek...
} else {
await send({ type: 'close' })
throw new DOMException(...SYNTAX('seek requires a position argument'))
}
} else if (chunk.type === 'truncate') {
if (Number.isInteger(chunk.size) && chunk.size >= 0) {
size = chunk.size
if (position > size) { position = size }
} else {
await send({ type: 'close' })
throw new DOMException(...SYNTAX('truncate requires a size argument'))
}
}

await send(chunk)
},
async close () {
await send({ type: 'close' })
worker.terminate()
},
async abort (reason) {
await send({ type: 'abort', reason })
worker.terminate()
},
})

return ws
}

const orig = FileSystemDirectoryHandle.prototype.getFileHandle
FileSystemDirectoryHandle.prototype.getFileHandle = async function (...args) {
const handle = await orig.call(this, ...args)
wm.set(handle, this)
return handle
}
}

export default FileSystemFileHandle
export { FileSystemFileHandle }
15 changes: 14 additions & 1 deletion src/FileSystemHandle.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
const kAdapter = Symbol('adapter')

/**
* @typedef {Object} FileSystemHandlePermissionDescriptor
* @property {('read'|'readwrite')} [mode='read']
*/
class FileSystemHandle {
/** @type {FileSystemHandle} */
[kAdapter]
Expand All @@ -16,7 +20,9 @@ class FileSystemHandle {
this[kAdapter] = adapter
}

async queryPermission ({mode = 'read'} = {}) {
/** @param {FileSystemHandlePermissionDescriptor} descriptor */
async queryPermission (descriptor = {}) {
const { mode = 'read' } = descriptor
const handle = this[kAdapter]

if (handle.queryPermission) {
Expand Down Expand Up @@ -79,5 +85,12 @@ Object.defineProperty(FileSystemHandle.prototype, Symbol.toStringTag, {
configurable: true
})

// Safari safari doesn't support writable streams yet.
if (globalThis.FileSystemHandle) {
globalThis.FileSystemHandle.prototype.queryPermission ??= function (descriptor) {
return 'granted'
}
}

export default FileSystemHandle
export { FileSystemHandle }
30 changes: 24 additions & 6 deletions src/FileSystemWritableFileStream.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@ import config from './config.js'
const { WritableStream } = config

class FileSystemWritableFileStream extends WritableStream {
constructor (...args) {
super(...args)

#writer
constructor (writer) {
super(writer)
this.#writer = writer
// Stupid Safari hack to extend native classes
// https://bugs.webkit.org/show_bug.cgi?id=226201
Object.setPrototypeOf(this, FileSystemWritableFileStream.prototype)
Expand All @@ -14,7 +15,7 @@ class FileSystemWritableFileStream extends WritableStream {
this._closed = false
}

close () {
async close () {
this._closed = true
const w = this.getWriter()
const p = w.close()
Expand All @@ -33,15 +34,23 @@ class FileSystemWritableFileStream extends WritableStream {
return this.write({ type: 'truncate', size })
}

// The write(data) method steps are:
write (data) {
if (this._closed) {
return Promise.reject(new TypeError('Cannot write to a CLOSED writable stream'))
}

// 1. Let writer be the result of getting a writer for this.
const writer = this.getWriter()
const p = writer.write(data)

// 2. Let result be the result of writing a chunk to writer given data.
const result = writer.write(data)

// 3. Release writer.
writer.releaseLock()
return p

// 4. Return result.
return result
}
}

Expand All @@ -59,5 +68,14 @@ Object.defineProperties(FileSystemWritableFileStream.prototype, {
write: { enumerable: true }
})

// Safari safari doesn't support writable streams yet.
if (
globalThis.FileSystemFileHandle &&
!globalThis.FileSystemFileHandle.prototype.createWritable &&
!globalThis.FileSystemWritableFileStream
) {
globalThis.FileSystemWritableFileStream = FileSystemWritableFileStream
}

export default FileSystemWritableFileStream
export { FileSystemWritableFileStream }
11 changes: 10 additions & 1 deletion src/adapters/indexeddb.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,16 @@

import { errors } from '../util.js'

const { INVALID, GONE, MISMATCH, MOD_ERR, SYNTAX } = errors
const { INVALID, GONE, MISMATCH, MOD_ERR, SYNTAX, ABORT } = errors

/**
* @param {IDBTransaction} tx
* @param {(e) => {}} onerror
*/
function setupTxErrorHandler (tx, onerror) {
tx.onerror = () => onerror(tx.error)
tx.onabort = () => onerror(tx.error || new DOMException(...ABORT))
}

class Sink {
/**
Expand Down
Loading