    Services.console.logStringMessage(
        "Attribution code cannot be written for MSIX builds, aborting."
      );
      return;
    }
    let file = AttributionCode.attributionFile;
    await IOUtils.makeDirectory(file.parent.path);
    let bytes = new TextEncoder().encode(code);
    await AttributionIOUtils.write(file.path, bytes);
  },

  /**
   * Returns an array of allowed attribution code keys.
   */
  get allowedCodeKeys() {
    return [...ATTR_CODE_KEYS];
  },

  /**
   * Returns an object containing a key-value pair for each piece of attribution
   * data included in the passed-in attribution code string.
   * If the string isn't a valid attribution code, returns an empty object.
   */
  parseAttributionCode(code) {
    if (code.length > ATTR_CODE_MAX_LENGTH) {
      return {};
    }

    let isValid = true;
    let parsed = {};
    for (let param of code.split(ATTR_CODE_FIELD_SEPARATOR)) {
      let [key, value] = param.split(ATTR_CODE_KEY_VALUE_SEPARATOR, 2);
      if (key && ATTR_CODE_KEYS.includes(key)) {
        if (value && ATTR_CODE_VALUE_REGEX.test(value)) {
          if (key === "msstoresignedin") {
            if (value === "true") {
              parsed[key] = true;
            } else if (value === "false") {
              parsed[key] = false;
            } else {
              throw new Error("Couldn't parse msstoresignedin");
            }
          } else {
            parsed[key] = value;
          }
        }
      } else if (param.startsWith(MSCLKID_KEY_PREFIX)) {
        // Microsoft Store Ads uses `_` to separate the key and value, therefore
        // requires special handling.
        parsed.msclkid = param.substring(MSCLKID_KEY_PREFIX.length);
      } else {
        lazy.log.debug(
          `parseAttributionCode: "${code}" => isValid = false: "${key}", "${value}"`
        );
        isValid = false;
        break;
      }
    }

    if (isValid) {
      return parsed;
    }

    Glean.browser.attributionErrors.decode_error.add(1);

    return {};
  },

  /**
   * Returns a string serializing the given attribution data.
   *
   * It is expected that the given values are already URL-encoded.
   */
  serializeAttributionData(data) {
    // Iterating in this way makes the order deterministic.
    let s = "";
    for (let key of ATTR_CODE_KEYS) {
      if (key in data) {
        let value = data[key];
        if (s) {
          s += ATTR_CODE_FIELD_SEPARATOR; // URL-encoded &
        }
        s += `${key}${ATTR_CODE_KEY_VALUE_SEPARATOR}${value}`; // URL-encoded =
      }
    }
    return s;
  },

  async _getMacAttrDataAsync() {
    // On macOS, we fish the attribution data from an extended attribute on
    // the .app bundle directory.
    try {
      let attrStr = await lazy.MacAttribution.getAttributionString();
      lazy.log.debug(
        `_getMacAttrDataAsync: getAttributionString: "${attrStr}"`
      );

      if (attrStr === null) {
        gCachedAttrData = {};

        lazy.log.debug(`_getMacAttrDataAsync: null attribution string`);
        Glean.browser.attributionErrors.null_error.add(1);
      } else if (attrStr == "") {
        gCachedAttrData = {};

        lazy.log.debug(`_getMacAttrDataAsync: empty attribution string`);
        Glean.browser.attributionErrors.empty_error.add(1);
      } else {
        gCachedAttrData = this.parseAttributionCode(attrStr);
      }
    } catch (ex) {
      // Avoid partial attribution data.
      gCachedAttrData = {};

      // No attributions.  Just `warn` 'cuz this isn't necessarily an error.
      lazy.log.warn("Caught exception fetching macOS attribution codes!", ex);

      if (
        ex instanceof Ci.nsIException &&
        ex.result == Cr.NS_ERROR_UNEXPECTED
      ) {
        // Bad quarantine data.
        Glean.browser.attributionErrors.quarantine_error.add(1);
      }
    }

    lazy.log.debug(
      `macOS attribution data is ${JSON.stringify(gCachedAttrData)}`
    );

    return gCachedAttrData;
  },

  async _getWindowsNSISAttrDataAsync() {
    return AttributionIOUtils.read(this.attributionFile.path);
  },

  async _getWindowsMSIXAttrDataAsync() {
    // This comes out of windows-package-manager _not_ URL encoded or in an ArrayBuffer,
    // but the parsing code wants it that way. It's easier to just provide that
    // than have the parsing code support both.
    lazy.log.debug(
      `winPackageFamilyName is: ${Services.sysinfo.getProperty(
        "winPackageFamilyName"
      )}`
    );
    let encoder = new TextEncoder();
    return encoder.encode(encodeURIComponent(await this.msixCampaignId()));
  },

  /**
   * Reads the attribution code, either from disk or a cached version.
   * Returns a promise that fulfills with an object containing the parsed
   * attribution data if the code could be read and is valid,
   * or an empty object otherwise.
   *
   * On windows the attribution service converts utm_* keys, removing "utm_".
   * On OSX the attributions are set directly on download and retain "utm_".  We
   * strip "utm_" while retrieving the params.
   */
  async getAttrDataAsync() {
    if (AppConstants.platform != "win" && AppConstants.platform != "macosx") {
      // This platform doesn't support attribution.
      return gCachedAttrData;
    }
    if (gCachedAttrData != null) {
      lazy.log.debug(
        `getAttrDataAsync: attribution is cached: ${JSON.stringify(
          gCachedAttrData
        )}`
      );
      return gCachedAttrData;
    }

    gCachedAttrData = {};

    if (AppConstants.platform == "macosx") {
      lazy.log.debug(`getAttrDataAsync: macOS`);
      return this._getMacAttrDataAsync();
    }

    lazy.log.debug("getAttrDataAsync: !macOS");

    let attributionFile = this.attributionFile;
    let bytes;
    try {
      if (
        AppConstants.platform === "win" &&
        Services.sysinfo.getProperty("hasWinPackageId")
      ) {
        lazy.log.debug("getAttrDataAsync: MSIX");
        bytes = await this._getWindowsMSIXAttrDataAsync();
      } else {
        lazy.log.debug("getAttrDataAsync: NSIS");
        bytes = await this._getWindowsNSISAttrDataAsync();
      }
    } catch (ex) {
      if (DOMException.isInstance(ex) && ex.name == "NotFoundError") {
        lazy.log.debug(
          `getAttrDataAsync: !exists("${
            attributionFile.path
          }"), returning ${JSON.stringify(gCachedAttrData)}`
        );
        return gCachedAttrData;
      }
      lazy.log.debug(
        `other error trying to read attribution data:
          attributionFile.path is: ${attributionFile.path}`
      );
      lazy.log.debug("Full exception is:");
      lazy.log.debug(ex);

      Glean.browser.attributionErrors.read_error.add(1);
    }
    if (bytes) {
      try {
        let decoder = new TextDecoder();
        let code = decoder.decode(bytes);
        lazy.log.debug(
          `getAttrDataAsync: attribution bytes deserializes to ${code}`
        );
        if (AppConstants.platform == "macosx" && !code) {
          // On macOS, an empty attribution code is fine.  (On Windows, that
          // means the stub/full installer has been incorrectly attributed,
          // which is an error.)
          return gCachedAttrData;
        }

        gCachedAttrData = this.parseAttributionCode(code);
        lazy.log.debug(
          `getAttrDataAsync: ${code} parses to ${JSON.stringify(
            gCachedAttrData
          )}`
        );
      } catch (ex) {
        // TextDecoder can throw an error
        Glean.browser.attributionErrors.decode_error.add(1);
      }
    }

    return gCachedAttrData;
  },

  /**
   * Return the cached attribution data synchronously without hitting
   * the disk.
   *
   * @returns A dictionary with the attribution data if it's available,
   *          null otherwise.
   */
  getCachedAttributionData() {
    return gCachedAttrData;
  },

  /**
   * Deletes the attribution data file.
   * Returns a promise that resolves when the file is deleted,
   * or if the file couldn't be deleted (the promise is never rejected).
   */
  async deleteFileAsync() {
    // There is no cache file on macOS
    if (AppConstants.platform == "win") {
      try {
        await IOUtils.remove(this.attributionFile.path);
      } catch (ex) {
        // The attribution file may already have been deleted,
        // or it may have never been installed at all;
        // failure to delete it isn't an error.
      }
    }
  },

  /**
   * Clears the cached attribution code value, if any.
   * Does nothing if called from outside of an xpcshell test.
   */
  _clearCache() {
    if (Services.env.exists("XPCSHELL_TEST_PROFILE_DIR")) {
      gCachedAttrData = null;
    }
  },
};
PK