summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/downloads.js
blob: 7f48fed8bddb09c4fa48c9b237bb7f9a9eaff108 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
/**
 * Where downloads go.
 *
 * A web page cannot be told a path. It cannot read "~/Downloads", cannot write
 * to it, and cannot be configured with "C:\Users\…" either — which is also why
 * none of this needs to change when someone runs it on Windows. What a browser
 * grants is a *handle* to a directory the user picked, once, in a dialog. That
 * handle is what this module keeps.
 *
 * Two modes, and the default matters:
 *
 *   auto (default) — write into the granted folder without asking. Downloading
 *     twelve files puts twelve files there. Without a granted folder, the file
 *     goes wherever the browser puts downloads, which on most machines is the
 *     same folder anyway.
 *   ask — a Save As dialog for every file, which is the right answer for one
 *     file and the wrong one for twelve.
 *
 * Firefox and Safari have no File System Access API at all: no folder can be
 * granted there, and both modes fall back to the browser's own download folder.
 * The setting says so rather than offering a choice that does nothing.
 */

const PREF_KEY = 'mb_dl_mode';
const IDB_NAME = 'meshbay-downloads';
const IDB_STORE = 'handles';
const HANDLE_KEY = 'target-dir';

export const SUPPORTED = typeof window !== 'undefined'
  && typeof window.showDirectoryPicker === 'function';

export function getMode() {
  const v = localStorage.getItem(PREF_KEY);
  return v === 'ask' ? 'ask' : 'auto';
}

export function setMode(mode) {
  localStorage.setItem(PREF_KEY, mode === 'ask' ? 'ask' : 'auto');
}

// ── The granted directory ────────────────────────────────────────────────────

function idb() {
  return new Promise((resolve, reject) => {
    const req = indexedDB.open(IDB_NAME, 1);
    req.onupgradeneeded = () => {
      if (!req.result.objectStoreNames.contains(IDB_STORE)) {
        req.result.createObjectStore(IDB_STORE);
      }
    };
    req.onsuccess = () => resolve(req.result);
    req.onerror = () => reject(req.error);
  });
}

async function idbGet(key) {
  try {
    const db = await idb();
    const tx = db.transaction(IDB_STORE, 'readonly');
    const req = tx.objectStore(IDB_STORE).get(key);
    const out = await new Promise((r, j) => {
      req.onsuccess = () => r(req.result); req.onerror = () => j(req.error);
    });
    db.close();
    return out || null;
  } catch { return null; }
}

async function idbPut(key, value) {
  try {
    const db = await idb();
    const tx = db.transaction(IDB_STORE, 'readwrite');
    if (value === null) tx.objectStore(IDB_STORE).delete(key);
    else tx.objectStore(IDB_STORE).put(value, key);
    await new Promise((r, j) => { tx.oncomplete = r; tx.onerror = j; });
    db.close();
  } catch { /* best effort: the setting simply will not stick */ }
}

/** The folder handle, if one was ever granted. Says nothing about permission. */
export async function savedDirectory() {
  return SUPPORTED ? idbGet(HANDLE_KEY) : null;
}

/** Ask for a folder. Must be called from a click — browsers require a gesture. */
export async function chooseDirectory() {
  const handle = await window.showDirectoryPicker({ mode: 'readwrite' });
  await idbPut(HANDLE_KEY, handle);
  return handle;
}

export async function forgetDirectory() {
  await idbPut(HANDLE_KEY, null);
}

/**
 * Permission survives a reload as a *claim*, not as a grant: the handle comes
 * back from IndexedDB in state "prompt" and has to be re-authorized once per
 * session. Doing that quietly during a download click is why this takes no
 * arguments and returns a boolean instead of throwing.
 */
export async function ensurePermission(handle, { prompt = true } = {}) {
  if (!handle) return false;
  try {
    const opts = { mode: 'readwrite' };
    if (await handle.queryPermission(opts) === 'granted') return true;
    if (!prompt) return false;
    return await handle.requestPermission(opts) === 'granted';
  } catch {
    return false;
  }
}

// ── Names ────────────────────────────────────────────────────────────────────

/**
 * A name not already taken, as "clip (2).mp4".
 *
 * Writing into a folder repeatedly is the whole point of the automatic mode, so
 * the second copy of a file must not silently replace the first. `exists` is
 * passed in so this can be tested without a filesystem.
 */
export async function freeName(name, exists) {
  if (!await exists(name)) return name;
  const dot = name.lastIndexOf('.');
  const stem = dot > 0 ? name.slice(0, dot) : name;
  const ext = dot > 0 ? name.slice(dot) : '';
  for (let n = 2; n < 1000; n++) {
    const candidate = `${stem} (${n})${ext}`;
    if (!await exists(candidate)) return candidate;
  }
  return `${stem} (${Date.now()})${ext}`;
}

/**
 * Where this download should be written, if a folder has been granted.
 *
 * Returns `{writable, name, open}` or null. Null does not mean failure: it
 * means there is no granted folder, and the caller decides between handing the
 * browser a blob and asking for a Save As.
 */
export async function openTarget(filename) {
  if (!SUPPORTED || getMode() === 'ask') return null;

  const dir = await savedDirectory();
  if (!dir || !await ensurePermission(dir)) return null;

  const exists = async (name) => {
    try {
      await dir.getFileHandle(name);
      return true;
    } catch {
      return false;
    }
  };
  const name = await freeName(filename, exists);
  const handle = await dir.getFileHandle(name, { create: true });
  return {
    writable: await handle.createWritable(),
    name,
    // Reading it back is the only way a page can "open" a file it wrote: hand
    // the bytes to a tab and let the browser decide what to do with them. No
    // web page can start a desktop application, or show a file manager.
    open: async () => {
      const file = await handle.getFile();
      const url = URL.createObjectURL(file);
      window.open(url, '_blank', 'noopener');
      setTimeout(() => URL.revokeObjectURL(url), 60000);
    },
  };
}

/**
 * Below this, a download with no granted folder and no service worker is
 * collected in memory and handed to the browser. Above it that would mean
 * holding gigabytes in a tab, so it is worth one Save As dialog instead.
 */
export const BLOB_LIMIT = 512 * 1024 * 1024;

// ── Streaming to disk without the File System Access API ────────────────────

const SW_PATH = '/sw.js';
let _swReady = null;

export const STREAMS_VIA_SW = typeof window !== 'undefined'
  && 'serviceWorker' in navigator
  && typeof TransformStream === 'function'
  && window.isSecureContext;

async function serviceWorker() {
  if (!STREAMS_VIA_SW) return null;
  if (!_swReady) {
    _swReady = navigator.serviceWorker.register(SW_PATH, { scope: '/' })
      .then(() => navigator.serviceWorker.ready)
      .then(reg => reg.active || navigator.serviceWorker.controller)
      .catch(err => {
        console.warn('[MeshBay] service worker unavailable:', err.message);
        return null;
      });
  }
  return _swReady;
}

/**
 * A sink the browser writes to disk, for Firefox and anything else without the
 * File System Access API.
 *
 * The page keeps the writable half of a stream and gives the readable half to
 * the service worker, which answers a made-up URL with it. Navigating a hidden
 * iframe there turns it into an ordinary download: written as it arrives, with
 * the browser's own progress, and nothing held in the tab. Backpressure is
 * real — `writer.write()` waits when the browser is behind.
 *
 * Returns {writable, name} shaped like the File System Access one, or null if
 * this browser cannot do it either.
 */
export async function openStreamedDownload(filename, size = 0) {
  const worker = await serviceWorker();
  if (!worker) return null;

  const id = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
  const { readable, writable } = new TransformStream();

  try {
    worker.postMessage({ type: 'mbdl', id, filename, size, readable }, [readable]);
  } catch (err) {
    // Transferable streams are what makes the backpressure work; without them
    // this would be a memory buffer wearing a stream's clothes.
    console.warn('[MeshBay] streams cannot be transferred here:', err.message);
    return null;
  }

  const frame = document.createElement('iframe');
  frame.hidden = true;
  frame.src = `/_mbdl/${id}`;
  document.body.appendChild(frame);

  const writer = writable.getWriter();
  return {
    name: filename,
    writable: {
      write: (bytes) => writer.write(bytes),
      close: async () => {
        await writer.close();
        setTimeout(() => frame.remove(), 2000);
      },
      abort: async (reason) => {
        try { await writer.abort(reason); } catch { /* already gone */ }
        frame.remove();
      },
    },
  };
}