summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js
blob: a005e2e3aeb6e811d823db08ac14eae8d7a41195 (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
import * as downloads from './downloads.js';
import * as platform from './platform.js';

const FILE_ICONS = {
  video: '\u{1F3AC}', audio: '\u{1F3B5}', image: '\u{1F5BC}',
  document: '\u{1F4C4}', archive: '\u{1F4E6}', other: '\u{1F4CE}',
};

function formatSize(bytes) {
  if (bytes < 1024) return bytes + ' B';
  if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
  if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
  return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
}

function formatDate(ts) {
  return new Date(ts * 1000).toLocaleDateString(undefined, {
    year: 'numeric', month: 'short', day: 'numeric',
  });
}

const PREVIEWABLE_TEXT =
  /\.(txt|md|json|csv|log|xml|yaml|yml|ini|conf|py|js|html|css|sh|c|h|java|rs|go|rb|toml)$/i;

function canPreview(e) {
  return ['image', 'video', 'audio', 'document'].includes(e.type)
    || PREVIEWABLE_TEXT.test(e.name);
}

const CHUNK_SIZE = 1024 * 1024;
// Segments allowed in flight while there is room to put them. This is a window,
// topped up as segments land, and not a debt released in one go: accumulating a
// credit per append and handing the lot over when the buffer finally had room
// sent 6 MB in a burst, overshot the target by a minute of film, and then said
// nothing for the next forty-six seconds. Measured in Chrome against real
// fragmented MP4. A stream that arrives in gulps has no margin for a network
// that hesitates, and looks like a hang while it is quiet.
const PIPELINE_WINDOW = 8;

/**
 * Open somewhere to write, honouring the user's download setting.
 *
 * Returns a target ({writable, name}), null for "no stream available — collect
 * it and hand the browser a blob", or false for "the person dismissed the
 * dialog", which is not an error and must not start a transfer.
 */
async function _openDownloadTarget(filename, size = 0, pickerOpts = {},
                                   swSize = size) {
  // On a desktop build this is the whole answer, and it comes first.
  //
  // The two browser paths below are both unavailable there — `showDirectoryPicker`
  // does not exist, and Chromium refuses a service worker on a custom scheme —
  // so without this the chain fell all the way through to its floor, which
  // collects the file in the page and hands the browser a blob. A gigabyte of
  // film meant a gigabyte of RAM, and a Save As dialog at the *end*.
  if (platform.capabilities.nativeSave) {
    try {
      const native = await platform.nativeSave(
        filename, { auto: downloads.getMode() === 'auto' });
      // Null means the person dismissed the dialog, which is not an error and
      // must not start a transfer.
      return native || false;
    } catch (err) {
      console.warn('[MeshBay] native save failed:', platform.bridgeMessage(err));
      return false;
    }
  }

  try {
    const target = await downloads.openTarget(filename);
    if (target) return target;
  } catch (err) {
    console.warn('[MeshBay] download folder unusable:', err.message);
  }

  // No granted folder. A service worker can still hand the browser a stream to
  // write, which is how this works at all in Firefox: the alternative there is
  // to collect gigabytes in a tab. It goes to the browser's own download
  // folder, without a dialog, which is what "save automatically" meant.
  if (downloads.getMode() === 'auto') {
    const streamed = await downloads.openStreamedDownload(filename, swSize);
    if (streamed) return streamed;
    // Nothing to stream to: small enough for memory, and no dialog.
    if (size < downloads.BLOB_LIMIT) return null;
  }

  if (!window.showSaveFilePicker) return null;
  try {
    const handle = await window.showSaveFilePicker({
      suggestedName: filename, ...pickerOpts,
    });
    return { writable: await handle.createWritable(), name: handle.name || filename };
  } catch (err) {
    if (err.name === 'AbortError') return false;
    throw err;
  }
}

/** The download of last resort, for browsers with no way to stream to disk. */
function _saveBlob(blob, filename) {
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url;
  a.download = filename;
  document.body.appendChild(a);
  a.click();
  document.body.removeChild(a);
  URL.revokeObjectURL(url);
}

function _b64ToU8(b64) {
  const bin = atob(b64);
  const arr = new Uint8Array(bin.length);
  for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
  return arr;
}

async function pipelinedDownload(transport, gekKey, fileId, totalChunks, onChunk,
                                 writable, signal) {
  const results = writable ? null : new Array(totalChunks);
  let nextSend = 0, nextRecv = 0;
  const inflight = new Array(totalChunks);

  const fire = () => {
    while (nextSend < totalChunks && nextSend - nextRecv < PIPELINE_WINDOW) {
      inflight[nextSend] = transport.fetchChunk(fileId, nextSend);
      nextSend++;
    }
  };

  fire();
  while (nextRecv < totalChunks) {
    if (signal && signal.aborted) {
      const err = new Error('Cancelled');
      err.name = 'AbortError';
      throw err;
    }
    const chunkMsg = await inflight[nextRecv];
    let plaintext;
    if (gekKey && chunkMsg.ct) {
      plaintext = await window.MeshBayCrypto.decryptChunkBin(
        gekKey, fileId, nextRecv, chunkMsg.nonce, chunkMsg.ct);
    } else if (gekKey && chunkMsg.ct_b64) {
      plaintext = await window.MeshBayCrypto.decryptChunk(
        gekKey, fileId, nextRecv, chunkMsg.nonce_b64, chunkMsg.ct_b64);
    } else {
      plaintext = _b64ToU8(chunkMsg.ct_b64 || chunkMsg.data_b64);
    }
    if (writable) {
      await writable.write(plaintext);
    } else {
      results[nextRecv] = plaintext;
    }
    nextRecv++;
    fire();
    if (onChunk) onChunk(plaintext.byteLength, nextRecv, totalChunks);
  }
  return results;
}

/**
 * Download one file through the transfers widget: picks a target, streams
 * and decrypts it, and falls back to a blob when there is nowhere to stream
 * to. Shared by the Files table/toolbar and the video/preview modals' own
 * download button — both just want "get this entry to disk".
 */
async function downloadEntry(transfers, transport, gek, entry) {
  const target = await _openDownloadTarget(entry.name, entry.size);
  if (target === false) return;          // the picker was dismissed

  const openRef = { url: null };
  transfers.start({
    kind: 'download', name: (target && target.name) || entry.name,
    total: entry.size, transport,
    open: target
      ? (target.open || null)
      : () => { if (openRef.url) window.open(openRef.url, '_blank'); },
    run: async ({ signal, onProgress }) => {
      const totalChunks = Math.ceil(entry.size / CHUNK_SIZE);
      let done = 0;
      const onChunk = (bytes) => { done += bytes; onProgress(done, entry.size); };

      if (target) {
        try {
          await pipelinedDownload(transport, gek, entry.id, totalChunks,
                                  onChunk, target.writable, signal);
          await target.writable.close();
        } catch (err) {
          await target.writable.abort().catch(() => {});
          throw err;
        }
      } else {
        const chunks = await pipelinedDownload(
          transport, gek, entry.id, totalChunks, onChunk, null, signal);
        const blob = new Blob(chunks);
        _saveBlob(blob, entry.name);
        openRef.url = URL.createObjectURL(blob);
      }
    },
  });
}

export {
  FILE_ICONS,
  formatSize, formatDate, PREVIEWABLE_TEXT, canPreview, CHUNK_SIZE,
  _openDownloadTarget, _saveBlob, _b64ToU8, pipelinedDownload, downloadEntry,
};