aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/downloads.js
blob: 9c6ced11561187fbe5392614bfe2568afd2f53b1 (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
/**
 * 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.
 *
 * Returns a writable stream, or null meaning "there is nowhere to stream to —
 * collect it and hand the browser a blob". The caller opens the picker itself
 * in "ask" mode, because that one has to happen inside the click.
 */
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 };
}