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 1 commit
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
Next Next commit
feat: fix missing createWritable in safari
  • Loading branch information
jimmywarting committed Jun 28, 2023
commit dcdb7dbff61c3475ab6ce6e6becc1efc474cf2c5
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 }
136 changes: 136 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,138 @@ Object.defineProperties(FileSystemFileHandle.prototype, {
getFile: { enumerable: true }
})

// Safari doesn't support async createWritable streams yet.
if (
globalThis.FileSystemFileHandle &&
!globalThis.FileSystemFileHandle.prototype.createWritable
) {
const workerUrl = new URL('./worker.js', import.meta.url).toString()
const wm = new WeakMap()

globalThis.FileSystemFileHandle.prototype.createWritable = async function (options) {
// Safari only support writing data in a worker with sync access handle.
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 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
3 changes: 2 additions & 1 deletion src/es6.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@ import showDirectoryPicker from './showDirectoryPicker.js'
import showOpenFilePicker from './showOpenFilePicker.js'
import showSaveFilePicker from './showSaveFilePicker.js'
import getOriginPrivateDirectory from './getOriginPrivateDirectory.js'
// FileSystemWritableFileStream must be loaded before FileSystemFileHandle
import FileSystemWritableFileStream from './FileSystemWritableFileStream.js'
import FileSystemDirectoryHandle from './FileSystemDirectoryHandle.js'
import FileSystemFileHandle from './FileSystemFileHandle.js'
import FileSystemHandle from './FileSystemHandle.js'
import FileSystemWritableFileStream from './FileSystemWritableFileStream.js'

export {
FileSystemDirectoryHandle,
Expand Down
10 changes: 3 additions & 7 deletions src/showDirectoryPicker.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,20 +14,16 @@ async function showDirectoryPicker (options = {}) {

const input = document.createElement('input')
input.type = 'file'

// Even with this check, the browser may support the attribute, but not the functionality (e.g. iOS Safari)
if (!('webkitdirectory' in input)) {
throw new Error(`HTMLInputElement.webkitdirectory is not supported`)
}
input.webkitdirectory = true
// Fallback to multiple files input for iOS Safari
input.multiple = true

// See https://stackoverflow.com/questions/47664777/javascript-file-input-onchange-not-working-ios-safari-only
input.style.position = 'fixed'
input.style.top = '-100000px'
input.style.left = '-100000px'
document.body.appendChild(input)

input.webkitdirectory = true

// Lazy load while the user is choosing the directory
const p = import('./util.js')

Expand Down
Loading