summaryrefslogtreecommitdiffstats
path: root/dom/manifest/Manifest.sys.mjs
blob: 97f786318f99931f0b061f375e570837dc059cc4 (plain)
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
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

/*
 * Manifest.sys.mjs is the top level api for managing installed web applications
 * https://www.w3.org/TR/appmanifest/
 *
 * It is used to trigger the installation of a web application via .install()
 * and to access the manifest data (including icons).
 *
 * TODO:
 *  - Trigger appropriate app installed events
 */

import { ManifestObtainer } from "resource://gre/modules/ManifestObtainer.sys.mjs";

import { ManifestIcons } from "resource://gre/modules/ManifestIcons.sys.mjs";

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  JSONFile: "resource://gre/modules/JSONFile.sys.mjs",
});

/**
 * Generates an hash for the given string.
 *
 * @note The generated hash is returned in base64 form.  Mind the fact base64
 * is case-sensitive if you are going to reuse this code.
 */
function generateHash(aString, hashAlg) {
  const cryptoHash = Cc["@mozilla.org/security/hash;1"].createInstance(
    Ci.nsICryptoHash
  );
  cryptoHash.init(hashAlg);
  const stringStream = Cc[
    "@mozilla.org/io/string-input-stream;1"
  ].createInstance(Ci.nsIStringInputStream);
  stringStream.data = aString;
  cryptoHash.updateFromStream(stringStream, -1);
  // base64 allows the '/' char, but we can't use it for filenames.
  return cryptoHash.finish(true).replace(/\//g, "-");
}

/**
 * Trims the query parameters from a url
 */
function stripQuery(url) {
  return url.split("?")[0];
}

// Folder in which we store the manifest files
const MANIFESTS_DIR = PathUtils.join(PathUtils.profileDir, "manifests");

// We maintain a list of scopes for installed webmanifests so we can determine
// whether a given url is within the scope of a previously installed manifest
const MANIFESTS_FILE = "manifest-scopes.json";

/**
 * Manifest object
 */

class Manifest {
  constructor(browser, manifestUrl) {
    this._manifestUrl = manifestUrl;
    // The key for this is the manifests URL that is required to be unique.
    // However arbitrary urls are not safe file paths so lets hash it.
    const filename =
      generateHash(manifestUrl, Ci.nsICryptoHash.SHA256) + ".json";
    this._path = PathUtils.join(MANIFESTS_DIR, filename);
    this.browser = browser;
  }

  /**
   * See Bug 1871109
   * This function is called at the beginning of initialize() to check if a given
   * manifest has MD5 based filename, if so we remove it and migrate the content to
   * a new file with SHA256 based name.
   * This is done due to security concern, as MD5 is an outdated hashing algorithm and
   * shouldn't be used anymore
   */
  async removeMD5BasedFilename() {
    const filenameMD5 =
      generateHash(this._manifestUrl, Ci.nsICryptoHash.MD5) + ".json";
    const MD5Path = PathUtils.join(MANIFESTS_DIR, filenameMD5);
    try {
      await IOUtils.copy(MD5Path, this._path, { noOverwrite: true });
    } catch (error) {
      // we are ignoring the failures returned from copy as it should not stop us from
      // installing a new manifest
    }

    // Remove the old MD5 based file unconditionally to ensure it's no longer used
    try {
      await IOUtils.remove(MD5Path);
    } catch {
      // ignore the error in case MD5 based file does not exist
    }
  }

  get browser() {
    return this._browser;
  }

  set browser(aBrowser) {
    this._browser = aBrowser;
  }

  async initialize() {
    await this.removeMD5BasedFilename();
    this._store = new lazy.JSONFile({ path: this._path, saveDelayMs: 100 });
    await this._store.load();
  }

  async prefetch(browser) {
    const manifestData = await ManifestObtainer.browserObtainManifest(browser);
    const icon = await ManifestIcons.browserFetchIcon(
      browser,
      manifestData,
      192
    );
    const data = {
      installed: false,
      manifest: manifestData,
      cached_icon: icon,
    };
    return data;
  }

  async install() {
    const manifestData = await ManifestObtainer.browserObtainManifest(
      this._browser
    );
    this._store.data = {
      installed: true,
      manifest: manifestData,
    };
    Manifests.manifestInstalled(this);
    this._store.saveSoon();
  }

  async icon(expectedSize) {
    if ("cached_icon" in this._store.data) {
      return this._store.data.cached_icon;
    }
    const icon = await ManifestIcons.browserFetchIcon(
      this._browser,
      this._store.data.manifest,
      expectedSize
    );
    // Cache the icon so future requests do not go over the network
    this._store.data.cached_icon = icon;
    this._store.saveSoon();
    return icon;
  }

  get scope() {
    const scope =
      this._store.data.manifest.scope || this._store.data.manifest.start_url;
    return stripQuery(scope);
  }

  get name() {
    return (
      this._store.data.manifest.short_name ||
      this._store.data.manifest.name ||
      this._store.data.manifest.short_url
    );
  }

  get url() {
    return this._manifestUrl;
  }

  get installed() {
    return (this._store.data && this._store.data.installed) || false;
  }

  get start_url() {
    return this._store.data.manifest.start_url;
  }

  get path() {
    return this._path;
  }
}

/*
 * Manifests maintains the list of installed manifests
 */
export var Manifests = {
  async _initialize() {
    if (this._readyPromise) {
      return this._readyPromise;
    }

    // Prevent multiple initializations
    this._readyPromise = (async () => {
      // Make sure the manifests have the folder needed to save into
      await IOUtils.makeDirectory(MANIFESTS_DIR, { ignoreExisting: true });

      // Ensure any existing scope data we have about manifests is loaded
      this._path = PathUtils.join(PathUtils.profileDir, MANIFESTS_FILE);
      this._store = new lazy.JSONFile({ path: this._path });
      await this._store.load();

      // If we don't have any existing data, initialize empty
      if (!this._store.data.hasOwnProperty("scopes")) {
        this._store.data.scopes = new Map();
      }
    })();

    // Cache the Manifest objects creates as they are references to files
    // and we do not want multiple file handles
    this.manifestObjs = new Map();
    return this._readyPromise;
  },

  // When a manifest is installed, we save its scope so we can determine if
  // future visits fall within this manifests scope
  manifestInstalled(manifest) {
    this._store.data.scopes[manifest.scope] = manifest.url;
    this._store.saveSoon();
  },

  // Given a url, find if it is within an installed manifests scope and if so
  // return that manifests url
  findManifestUrl(url) {
    for (let scope in this._store.data.scopes) {
      if (url.startsWith(scope)) {
        return this._store.data.scopes[scope];
      }
    }
    return null;
  },

  // Get the manifest given a url, or if not look for a manifest that is
  // tied to the current page
  async getManifest(browser, manifestUrl) {
    // Ensure we have all started up
    if (!this._readyPromise) {
      await this._initialize();
    }

    // If the client does not already know its manifestUrl, we take the
    // url of the client and see if it matches the scope of any installed
    // manifests
    if (!manifestUrl) {
      const url = stripQuery(browser.currentURI.spec);
      manifestUrl = this.findManifestUrl(url);
    }

    // No matches so no manifest
    if (manifestUrl === null) {
      return null;
    }

    // If we have already created this manifest return cached
    if (this.manifestObjs.has(manifestUrl)) {
      const manifest = this.manifestObjs.get(manifestUrl);
      if (manifest.browser !== browser) {
        manifest.browser = browser;
      }
      return manifest;
    }

    // Otherwise create a new manifest object
    const manifest = new Manifest(browser, manifestUrl);
    this.manifestObjs.set(manifestUrl, manifest);
    await manifest.initialize();
    return manifest;
  },
};