From 0e587dc80c3c708e14d4c1012c50c3f39a4cab28 Mon Sep 17 00:00:00 2001 From: Allen Houchins <32207388+allenhouchins@users.noreply.github.com> Date: Fri, 17 Jul 2026 16:05:27 -0500 Subject: [PATCH] Add Firefox Nightly and Firefox Developer Edition Fleet-maintained apps (#49501) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Related issue:** #49149 Adds **Firefox Nightly** and **Firefox Developer Edition** as Fleet-maintained apps for macOS and Windows (4 new FMAs). Mozilla VPN (also requested in #49149) is not included, so this doesn't close the issue. ## What's included | App | macOS | Windows | |-----|-------|---------| | Firefox Nightly | Homebrew cask `firefox@nightly`, bundle ID `org.mozilla.nightly` | MSIX `Mozilla.Firefox.Nightly.MSIX` (no exe package exists in winget) | | Firefox Developer Edition | Homebrew cask `firefox@developer-edition`, bundle ID `org.mozilla.firefoxdeveloperedition` | NSIS exe `Mozilla.Firefox.DeveloperEdition`, machine scope, `/S` | All identity fields were verified against the real installers, not catalog metadata: bundle IDs read from the DMGs' `Info.plist`, MSIX identity ("Firefox Nightly" / publisher "Mozilla Corporation") read from the package's `AppxManifest.xml`, and DevEd's registry identity (`Firefox Developer Edition (x64 en-US)`, publisher `Mozilla`, versionless DisplayName) confirmed in Mozilla's NSIS installer source at the shipped release tags. The MSIX install/uninstall scripts follow the established Slack/Claude pattern (provision machine-wide + register for the logged-on user via scheduled task). ## Pre-release version handling (macOS) Mozilla's pre-release bundles report only the base version in `CFBundleShortVersionString` (`153.0` for cask version `153.0b13`), which would make the generated patch policy flag every up-to-date install as outdated. Instead, the patch policies for these two channels compare **`bundle_version`** (`CFBundleVersion` encodes the build date, e.g. `15326.7.15`), so individual pre-release builds are distinguishable: - **Nightly**: derived directly from the build timestamp embedded in the cask version (`154.0a1,2026-07-17-09-27-13` → `15426.7.17`). No external calls. - **Developer Edition**: build date resolved from Mozilla's [buildhub](https://buildhub.moz.tools) (indexed as product `firefox`, channel `aurora`). On buildhub failure the ingester logs a warning and falls back to a base-version comparison (cycle granularity) rather than failing the run; it self-corrects on the next scheduled ingestion. The macOS validator gets a matching exception accepting `base + "b"` versions for the DevEd bundle ID. Both computed values were verified against the actual DMGs' `Info.plist`. **Known limitation (Windows):** patch detection for Developer Edition on Windows is only accurate to the release cycle (153 vs 154), not between betas (b13 vs b14) — the registry `DisplayVersion` and winget's `PackageVersion` are both the base version (`151.0`) with no beta number, and osquery's `programs` table has nothing finer to compare. Nightly on Windows is day-granular since its MSIX package version encodes the build date. Documented in #49149. ## Other changes - Tightened `firefox_uninstall.ps1` (release channel FMA): its `*Firefox*` match would have uninstalled Developer Edition (or ESR) first depending on registry enumeration order. Now matches `Mozilla Firefox*` and excludes ESR; `firefox/windows` output regenerated. - Channel-specific icons generated from the real app bundles (frontend components + website PNGs), with map keys covering both catalog and inventory names. ## Why Firefox Beta is not included Beta (requested in #49149) was deliberately dropped: - On Windows, the Beta exe registers the same identity as release Firefox (`Mozilla Firefox (x64 en-US)`), making it indistinguishable in inventory. The only distinct package is `Mozilla.Firefox.Beta.MSIX`, which has no `en-US` winget locale manifest (only `en`), which the ingester requires. - On macOS, Beta shares both the bundle ID (`org.mozilla.firefox`) and the install path (`/Applications/Firefox.app`) with release Firefox, so installing it silently replaces a release install. ## Notes for reviewers - Nightly URLs are date-stamped and refresh with the daily FMA ingestion; within-cycle Nightly darwin builds share a `CFBundleVersion` date so same-day rebuilds are indistinguishable. - DevEd's winget package lags Mozilla's releases (winget latest is 151.0 vs current 153.0b13); the FMA tracks winget as usual. - The `CFBundleVersion` format (`..`, unpadded) is an inferred contract of Mozilla's build system, verified against both shipped DMGs; if it ever changes, patch status fails visibly (reports unpatched), not silently. # Checklist for submitter If some of the following don't apply, delete the relevant line. - [x] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements), JS inline code is prevented especially for url redirects, and untrusted data interpolated into shell scripts/commands is validated against shell metacharacters. - [x] Timeouts are implemented and retries are limited to avoid infinite loops ## Testing - [x] Added/updated automated tests - [ ] QA'd all new/changed functionality manually ## Summary by CodeRabbit * **New Features** * Added deployment support for Mozilla Firefox Developer Edition and Firefox Nightly on macOS and Windows (including Homebrew/Winget inputs and platform-specific install/uninstall workflows). * Added new catalog entries and dedicated UI icons for both Firefox variants. * **Bug Fixes** * Improved macOS “patched” detection and version matching for Developer Edition and Nightly builds. * Enhanced Homebrew ingestion by resolving build information (with fallback when unavailable). * Refined Windows uninstallation matching to target the correct Firefox release channels and avoid ESR. --- cmd/maintained-apps/validate/darwin.go | 9 + .../ingesters/homebrew/ingester.go | 201 +++++++++++++++++- .../ingesters/homebrew/ingester_test.go | 168 +++++++++++++++ .../homebrew/firefox@developer-edition.json | 8 + .../inputs/homebrew/firefox@nightly.json | 8 + .../winget/firefox@developer-edition.json | 12 ++ .../inputs/winget/firefox@nightly.json | 13 ++ .../firefox_developer_edition_install.ps1 | 27 +++ .../firefox_developer_edition_uninstall.ps1 | 90 ++++++++ .../scripts/firefox_nightly_install.ps1 | 99 +++++++++ .../scripts/firefox_nightly_uninstall.ps1 | 53 +++++ .../winget/scripts/firefox_uninstall.ps1 | 12 +- ee/maintained-apps/outputs/apps.json | 28 +++ .../outputs/firefox/windows.json | 4 +- .../firefox@developer-edition/darwin.json | 22 ++ .../firefox@developer-edition/windows.json | 22 ++ .../outputs/firefox@nightly/darwin.json | 22 ++ .../outputs/firefox@nightly/windows.json | 22 ++ .../icons/FirefoxDeveloperEdition.tsx | 14 ++ .../components/icons/FirefoxNightly.tsx | 14 ++ .../SoftwarePage/components/icons/index.ts | 6 + ...con-firefox@developer-edition-60x60@2x.png | Bin 0 -> 15646 bytes .../app-icon-firefox@nightly-60x60@2x.png | Bin 0 -> 14681 bytes 23 files changed, 843 insertions(+), 11 deletions(-) create mode 100644 ee/maintained-apps/inputs/homebrew/firefox@developer-edition.json create mode 100644 ee/maintained-apps/inputs/homebrew/firefox@nightly.json create mode 100644 ee/maintained-apps/inputs/winget/firefox@developer-edition.json create mode 100644 ee/maintained-apps/inputs/winget/firefox@nightly.json create mode 100644 ee/maintained-apps/inputs/winget/scripts/firefox_developer_edition_install.ps1 create mode 100644 ee/maintained-apps/inputs/winget/scripts/firefox_developer_edition_uninstall.ps1 create mode 100644 ee/maintained-apps/inputs/winget/scripts/firefox_nightly_install.ps1 create mode 100644 ee/maintained-apps/inputs/winget/scripts/firefox_nightly_uninstall.ps1 create mode 100644 ee/maintained-apps/outputs/firefox@developer-edition/darwin.json create mode 100644 ee/maintained-apps/outputs/firefox@developer-edition/windows.json create mode 100644 ee/maintained-apps/outputs/firefox@nightly/darwin.json create mode 100644 ee/maintained-apps/outputs/firefox@nightly/windows.json create mode 100644 frontend/pages/SoftwarePage/components/icons/FirefoxDeveloperEdition.tsx create mode 100644 frontend/pages/SoftwarePage/components/icons/FirefoxNightly.tsx create mode 100644 website/assets/images/app-icon-firefox@developer-edition-60x60@2x.png create mode 100644 website/assets/images/app-icon-firefox@nightly-60x60@2x.png diff --git a/cmd/maintained-apps/validate/darwin.go b/cmd/maintained-apps/validate/darwin.go index 08e89c6602..1eef53d20b 100644 --- a/cmd/maintained-apps/validate/darwin.go +++ b/cmd/maintained-apps/validate/darwin.go @@ -294,6 +294,15 @@ func appExists(ctx context.Context, logger *slog.Logger, appName, uniqueAppIdent } } + // The Developer Edition cask version is the full beta ("153.0b13") but + // the bundle reports only the base version ("153.0"); accept base+"b". + if uniqueAppIdentifier == "org.mozilla.firefoxdeveloperedition" { + if result.Version != "" && strings.HasPrefix(appVersion, result.Version+"b") { + logger.InfoContext(ctx, "Firefox Developer Edition detected - cask version matches bundle base version with beta suffix") + return true, nil + } + } + // Check various version matching strategies if checkVersionMatch(appVersion, result.Version, result.BundledVersion) { return true, nil diff --git a/ee/maintained-apps/ingesters/homebrew/ingester.go b/ee/maintained-apps/ingesters/homebrew/ingester.go index c346a487aa..6e53374bd5 100644 --- a/ee/maintained-apps/ingesters/homebrew/ingester.go +++ b/ee/maintained-apps/ingesters/homebrew/ingester.go @@ -1,6 +1,7 @@ package homebrew import ( + "bytes" "context" "encoding/json" "errors" @@ -11,6 +12,7 @@ import ( "net/url" "os" "path" + "regexp" "strings" "time" @@ -33,6 +35,7 @@ func IngestApps(ctx context.Context, logger *slog.Logger, inputsPath, slugFilter i := &brewIngester{ baseURL: baseBrewAPIURL, + buildhubURL: buildhubAPIURL, logger: logger, client: fleethttp.NewClient(fleethttp.WithTimeout(10 * time.Second)), retryInterval: 2 * time.Second, @@ -91,12 +94,17 @@ func IngestApps(ctx context.Context, logger *slog.Logger, inputsPath, slugFilter return manifestApps, nil } -const baseBrewAPIURL = "https://formulae.brew.sh/api/" +const ( + baseBrewAPIURL = "https://formulae.brew.sh/api/" + // buildhubAPIURL is Mozilla's build metadata search API. + buildhubAPIURL = "https://buildhub.moz.tools/api/search" +) type brewIngester struct { - baseURL string - logger *slog.Logger - client *http.Client + baseURL string + buildhubURL string + logger *slog.Logger + client *http.Client // retryInterval and retryMaxAttempts control retries of transient brew API // failures (network errors and 5xx/429 responses). formulae.brew.sh is @@ -239,10 +247,195 @@ func (i *brewIngester) ingestOne(ctx context.Context, input inputApp) (*maintain out.UniqueIdentifier, out.Version, ) } + if input.Token == "firefox@developer-edition" { + // The bundle reports only the base version ("153.0") for cask version + // "153.0b13", so compare CFBundleVersion (encodes the build date, resolved + // via buildhub) to distinguish betas; fall back to a cycle-granular + // base-version comparison if buildhub is unavailable. + column := "bundle_version" + patchVersion, err := i.firefoxDevEditionMacBundleVersion(ctx, out.Version) + if err != nil { + i.logger.WarnContext(ctx, "resolving Firefox Developer Edition bundle version failed; patch policy falls back to base-version comparison", "err", err.Error()) + column, patchVersion = "bundle_short_version", firefoxBetaBaseVersion(out.Version) + } + out.Queries.Patched = fmt.Sprintf( + "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM apps WHERE bundle_identifier = '%s' AND version_compare(%s, '%s') < 0);", + out.UniqueIdentifier, column, patchVersion, + ) + } + if input.Token == "firefox@nightly" { + // Nightly's CFBundleShortVersionString ("154.0a1") is constant all cycle; + // derive CFBundleVersion from the cask version's build timestamp for + // day-level patch status. + bundleVersion, err := firefoxNightlyMacBundleVersion(cask.Version) + if err != nil { + i.logger.WarnContext(ctx, "deriving Firefox Nightly bundle version failed; patch policy falls back to short-version comparison", "err", err.Error()) + } else { + out.Queries.Patched = fmt.Sprintf( + "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM apps WHERE bundle_identifier = '%s' AND version_compare(bundle_version, '%s') < 0);", + out.UniqueIdentifier, bundleVersion, + ) + } + } return out, nil } +var firefoxBetaVersionPattern = regexp.MustCompile(`^(\d+(?:\.\d+)*)b\d+$`) + +// firefoxBetaBaseVersion strips the beta suffix from a Firefox pre-release +// version ("153.0b13" -> "153.0"); non-matching versions pass through unchanged. +func firefoxBetaBaseVersion(version string) string { + if m := firefoxBetaVersionPattern.FindStringSubmatch(version); m != nil { + return m[1] + } + return version +} + +// firefoxMacBundleVersion computes a Firefox mac build's CFBundleVersion: +// "..", unpadded ("153.0b13" + "20260715" -> "15326.7.15"). +func firefoxMacBundleVersion(version, buildDate string) (string, error) { + major, _, _ := strings.Cut(version, ".") + if major == "" || strings.Trim(major, "0123456789") != "" { + return "", fmt.Errorf("cannot parse major version from %q", version) + } + if len(buildDate) < 8 { + return "", fmt.Errorf("invalid build date %q", buildDate) + } + date, err := time.Parse("20060102", buildDate[:8]) + if err != nil { + return "", fmt.Errorf("invalid build date %q", buildDate) + } + yy := buildDate[2:4] + return fmt.Sprintf("%s%s.%d.%d", major, yy, int(date.Month()), date.Day()), nil +} + +// firefoxNightlyCaskVersionPattern extracts the build timestamp from a Firefox +// Nightly cask version ("154.0a1,2026-07-17-09-27-13"). +var firefoxNightlyCaskVersionPattern = regexp.MustCompile(`^[^,]+,(\d{4})-(\d{2})-(\d{2})(?:-|$)`) + +// firefoxNightlyMacBundleVersion derives CFBundleVersion from a Nightly cask +// version ("154.0a1,2026-07-17-09-27-13" -> "15426.7.17"). +func firefoxNightlyMacBundleVersion(caskVersion string) (string, error) { + m := firefoxNightlyCaskVersionPattern.FindStringSubmatch(caskVersion) + if m == nil { + return "", fmt.Errorf("cask version %q has no build timestamp", caskVersion) + } + return firefoxMacBundleVersion(caskVersion, m[1]+m[2]+m[3]) +} + +// firefoxDevEditionMacBundleVersion resolves a Developer Edition mac build's +// CFBundleVersion ("153.0b13" -> "15326.7.15") by looking up its build id in +// buildhub, where DevEd is indexed as product "firefox", channel "aurora". +func (i *brewIngester) firefoxDevEditionMacBundleVersion(ctx context.Context, version string) (string, error) { + type term map[string]map[string]string + reqBody, err := json.Marshal(map[string]any{ + "size": 1, + "query": map[string]any{ + "bool": map[string]any{ + "must": []term{ + {"term": {"source.product": "firefox"}}, + {"term": {"target.channel": "aurora"}}, + {"term": {"target.platform": "mac"}}, + {"term": {"target.version": version}}, + }, + }, + }, + "sort": []term{{"build.id": {"order": "desc"}}}, + }) + if err != nil { + return "", ctxerr.Wrap(ctx, err, "marshal buildhub query") + } + + interval := i.retryInterval + if interval <= 0 { + interval = 2 * time.Second + } + maxAttempts := i.retryMaxAttempts + if maxAttempts <= 0 { + maxAttempts = 5 + } + + var body []byte + attempt := 0 + err = retry.Do(func() error { + attempt++ + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, i.buildhubURL, bytes.NewReader(reqBody)) + if err != nil { + return ctxerr.Wrap(ctx, err, "create buildhub http request") + } + req.Header.Set("Content-Type", "application/json") + + res, err := i.client.Do(req) + if err != nil { + // Caller cancellation/deadline is not transient; stop retrying. + if ctxErr := ctx.Err(); ctxErr != nil { + return ctxErr + } + i.logger.WarnContext(ctx, "buildhub request failed, retrying", "attempt", attempt, "err", err.Error()) + return &transientErr{ctxerr.Wrap(ctx, err, "execute buildhub http request")} + } + defer res.Body.Close() + + body, err = io.ReadAll(res.Body) + if err != nil { + if ctxErr := ctx.Err(); ctxErr != nil { + return ctxErr + } + i.logger.WarnContext(ctx, "reading buildhub response failed, retrying", "attempt", attempt, "err", err.Error()) + return &transientErr{ctxerr.Wrap(ctx, err, "read buildhub response body")} + } + + switch res.StatusCode { + case http.StatusOK: + return nil + case http.StatusTooManyRequests, + http.StatusInternalServerError, + http.StatusBadGateway, + http.StatusServiceUnavailable, + http.StatusGatewayTimeout: + i.logger.WarnContext(ctx, "buildhub returned transient error, retrying", "attempt", attempt, "status", res.StatusCode) + return &transientErr{ctxerr.Errorf(ctx, "buildhub returned status %d: %s", res.StatusCode, truncateBody(body))} + default: + return ctxerr.Errorf(ctx, "buildhub returned status %d: %s", res.StatusCode, truncateBody(body)) + } + }, + retry.WithInterval(interval), + retry.WithBackoffMultiplier(2), + retry.WithMaxAttempts(maxAttempts), + retry.WithErrorFilter(func(err error) retry.ErrorOutcome { + if _, ok := errors.AsType[*transientErr](err); ok { + return retry.ErrorOutcomeNormalRetry + } + return retry.ErrorOutcomeDoNotRetry + }), + ) + if err != nil { + return "", err + } + + var resp struct { + Hits struct { + Hits []struct { + Source struct { + Build struct { + ID string `json:"id"` + } `json:"build"` + } `json:"_source"` + } `json:"hits"` + } `json:"hits"` + } + if err := json.Unmarshal(body, &resp); err != nil { + return "", ctxerr.Wrap(ctx, err, "unmarshal buildhub response") + } + if len(resp.Hits.Hits) == 0 { + return "", ctxerr.Errorf(ctx, "no buildhub build found for version %s", version) + } + + return firefoxMacBundleVersion(version, resp.Hits.Hits[0].Source.Build.ID) +} + // fetchCask resolves the brew cask JSON for the given input app from // either a local file (cask_path) or the default brew API. func (i *brewIngester) fetchCask(ctx context.Context, input inputApp) (brewCask, error) { diff --git a/ee/maintained-apps/ingesters/homebrew/ingester_test.go b/ee/maintained-apps/ingesters/homebrew/ingester_test.go index de9f138879..1fd5446022 100644 --- a/ee/maintained-apps/ingesters/homebrew/ingester_test.go +++ b/ee/maintained-apps/ingesters/homebrew/ingester_test.go @@ -95,6 +95,22 @@ func TestIngestValidations(t *testing.T) { Version: "1.0", } + case "firefox@developer-edition": + cask = brewCask{ + Token: appToken, + Name: []string{"Mozilla Firefox Developer Edition"}, + URL: "https://example.com", + Version: "153.0b13", + } + + case "firefox@nightly": + cask = brewCask{ + Token: appToken, + Name: []string{"Mozilla Firefox Nightly"}, + URL: "https://example.com", + Version: "154.0a1,2026-07-17-09-27-13", + } + default: w.WriteHeader(http.StatusBadRequest) t.Fatalf("unexpected app token %s", appToken) @@ -105,6 +121,12 @@ func TestIngestValidations(t *testing.T) { })) t.Cleanup(srv.Close) + // buildhub stub: DevEd 153.0b13 build id -> CFBundleVersion "15326.7.15". + buildhubSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"hits":{"hits":[{"_source":{"build":{"id":"20260715125817"}}}]}}`)) + })) + t.Cleanup(buildhubSrv.Close) + ctx := context.Background() cases := []struct { @@ -121,6 +143,8 @@ func TestIngestValidations(t *testing.T) { {"parse URL for cask invalidurl", inputApp{Token: "invalidurl", UniqueIdentifier: "abc", InstallerFormat: "pkg"}}, {"", inputApp{Token: "ok", UniqueIdentifier: "abc", InstallerFormat: "pkg"}}, {"", inputApp{Token: "docker-desktop", UniqueIdentifier: "com.electron.dockerdesktop", InstallerFormat: "dmg", Name: "Docker Desktop", Slug: "docker-desktop/darwin"}}, + {"", inputApp{Token: "firefox@developer-edition", UniqueIdentifier: "org.mozilla.firefoxdeveloperedition", InstallerFormat: "dmg", Name: "Mozilla Firefox Developer Edition", Slug: "firefox@developer-edition/darwin"}}, + {"", inputApp{Token: "firefox@nightly", UniqueIdentifier: "org.mozilla.nightly", InstallerFormat: "dmg", Name: "Mozilla Firefox Nightly", Slug: "firefox@nightly/darwin"}}, {"", inputApp{Token: "swiftdialog", UniqueIdentifier: "au.csiro.dialog", InstallerFormat: "pkg", Name: "swiftDialog", Slug: "swiftdialog/darwin"}}, {"", inputApp{Token: "install_script_path", UniqueIdentifier: "abc", InstallerFormat: "pkg", InstallScriptPath: path.Join(tempDir, "install_script.sh")}}, {"", inputApp{Token: "uninstall_script_path", UniqueIdentifier: "abc", InstallerFormat: "pkg", UninstallScriptPath: path.Join(tempDir, "uninstall_script.sh")}}, @@ -133,6 +157,7 @@ func TestIngestValidations(t *testing.T) { logger: slog.New(slog.DiscardHandler), client: fleethttp.NewClient(fleethttp.WithTimeout(10 * time.Second)), baseURL: srv.URL + "/", + buildhubURL: buildhubSrv.URL, retryInterval: time.Millisecond, retryMaxAttempts: 3, } @@ -160,6 +185,21 @@ func TestIngestValidations(t *testing.T) { "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM apps WHERE bundle_identifier = 'com.electron.dockerdesktop' AND path NOT LIKE '%.back' AND version_compare(bundle_short_version, '1.0') < 0);", out.Queries.Patched, ) + case "firefox@developer-edition": + // Patched query compares the buildhub-resolved CFBundleVersion. + require.Equal(t, "153.0b13", out.Version) + require.Equal(t, + "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM apps WHERE bundle_identifier = 'org.mozilla.firefoxdeveloperedition' AND version_compare(bundle_version, '15326.7.15') < 0);", + out.Queries.Patched, + ) + case "firefox@nightly": + // Patched query compares the CFBundleVersion derived from the cask + // version's build timestamp. + require.Equal(t, "154.0a1", out.Version) + require.Equal(t, + "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM apps WHERE bundle_identifier = 'org.mozilla.nightly' AND version_compare(bundle_version, '15426.7.17') < 0);", + out.Queries.Patched, + ) case "swiftdialog": require.Equal(t, "SELECT 1 FROM apps WHERE bundle_identifier = 'au.csiro.dialog' AND path != '/opt/orbit/bin/swiftDialog/macos/stable/Dialog.app';", out.Queries.Exists) require.Equal(t, @@ -361,3 +401,131 @@ func TestIngestCaskPath(t *testing.T) { require.ErrorContains(t, err, "empty name") require.Equal(t, 0, httpHits) } + +func TestFirefoxBetaBaseVersion(t *testing.T) { + cases := []struct { + in string + want string + }{ + {"153.0b13", "153.0"}, + {"154.0b1", "154.0"}, + {"153.0.1b2", "153.0.1"}, + {"153.0", "153.0"}, + {"152.0.6", "152.0.6"}, + {"154.0a1", "154.0a1"}, + {"", ""}, + } + for _, c := range cases { + require.Equal(t, c.want, firefoxBetaBaseVersion(c.in), "input %q", c.in) + } +} + +func TestFirefoxMacBundleVersion(t *testing.T) { + cases := []struct { + version string + buildDate string + want string + wantErr bool + }{ + {"153.0b13", "20260715125817", "15326.7.15", false}, + {"154.0a1", "20260717", "15426.7.17", false}, + {"153.0.1b2", "20261201000000", "15326.12.1", false}, + {"153.0b13", "2026071", "", true}, // build date too short + {"153.0b13", "2026x715", "", true}, // build date not numeric + {"153.0b13", "20261315000000", "", true}, // month out of range + {"153.0b13", "20260732000000", "", true}, // day out of range + {"153.0b13", "20260231000000", "", true}, // impossible calendar date + {"x.0b13", "20260715125817", "", true}, // non-numeric major + {"", "20260715125817", "", true}, + } + for _, c := range cases { + got, err := firefoxMacBundleVersion(c.version, c.buildDate) + if c.wantErr { + require.Error(t, err, "version %q buildDate %q", c.version, c.buildDate) + continue + } + require.NoError(t, err, "version %q buildDate %q", c.version, c.buildDate) + require.Equal(t, c.want, got, "version %q buildDate %q", c.version, c.buildDate) + } +} + +func TestFirefoxNightlyMacBundleVersion(t *testing.T) { + cases := []struct { + caskVersion string + want string + wantErr bool + }{ + {"154.0a1,2026-07-17-09-27-13", "15426.7.17", false}, + {"154.0a1,2026-07-17", "15426.7.17", false}, + {"154.0a1", "", true}, // no build timestamp + {"154.0a1,not-a-date", "", true}, // malformed timestamp + {"154.0a1,2026-13-17-09-27-13", "", true}, // month out of range + } + for _, c := range cases { + got, err := firefoxNightlyMacBundleVersion(c.caskVersion) + if c.wantErr { + require.Error(t, err, "caskVersion %q", c.caskVersion) + continue + } + require.NoError(t, err, "caskVersion %q", c.caskVersion) + require.Equal(t, c.want, got, "caskVersion %q", c.caskVersion) + } +} + +// TestFirefoxDevEditionBuildhubFallback verifies that a buildhub failure falls +// back to a base-version patch comparison instead of failing ingestion. +func TestFirefoxDevEditionBuildhubFallback(t *testing.T) { + brewSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + err := json.NewEncoder(w).Encode(brewCask{ + Token: "firefox@developer-edition", + Name: []string{"Mozilla Firefox Developer Edition"}, + URL: "https://example.com", + Version: "153.0b13", + }) + if err != nil { + t.Errorf("encoding fixture: %v", err) + } + })) + t.Cleanup(brewSrv.Close) + + cases := []struct { + name string + handler http.HandlerFunc + }{ + {"buildhub has no matching build", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"hits":{"hits":[]}}`)) + }}, + {"buildhub is unavailable", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + }}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + buildhubSrv := httptest.NewServer(c.handler) + t.Cleanup(buildhubSrv.Close) + + i := &brewIngester{ + logger: slog.New(slog.DiscardHandler), + client: fleethttp.NewClient(fleethttp.WithTimeout(10 * time.Second)), + baseURL: brewSrv.URL + "/", + buildhubURL: buildhubSrv.URL, + retryInterval: time.Millisecond, + retryMaxAttempts: 2, + } + + out, err := i.ingestOne(context.Background(), inputApp{ + Token: "firefox@developer-edition", + UniqueIdentifier: "org.mozilla.firefoxdeveloperedition", + InstallerFormat: "dmg", + Name: "Mozilla Firefox Developer Edition", + Slug: "firefox@developer-edition/darwin", + }) + require.NoError(t, err) + require.Equal(t, "153.0b13", out.Version) + require.Equal(t, + "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM apps WHERE bundle_identifier = 'org.mozilla.firefoxdeveloperedition' AND version_compare(bundle_short_version, '153.0') < 0);", + out.Queries.Patched, + ) + }) + } +} diff --git a/ee/maintained-apps/inputs/homebrew/firefox@developer-edition.json b/ee/maintained-apps/inputs/homebrew/firefox@developer-edition.json new file mode 100644 index 0000000000..e7898efbae --- /dev/null +++ b/ee/maintained-apps/inputs/homebrew/firefox@developer-edition.json @@ -0,0 +1,8 @@ +{ + "name": "Mozilla Firefox Developer Edition", + "slug": "firefox@developer-edition/darwin", + "unique_identifier": "org.mozilla.firefoxdeveloperedition", + "token": "firefox@developer-edition", + "installer_format": "dmg", + "default_categories": ["Browsers"] +} diff --git a/ee/maintained-apps/inputs/homebrew/firefox@nightly.json b/ee/maintained-apps/inputs/homebrew/firefox@nightly.json new file mode 100644 index 0000000000..e2701fcd07 --- /dev/null +++ b/ee/maintained-apps/inputs/homebrew/firefox@nightly.json @@ -0,0 +1,8 @@ +{ + "name": "Mozilla Firefox Nightly", + "slug": "firefox@nightly/darwin", + "unique_identifier": "org.mozilla.nightly", + "token": "firefox@nightly", + "installer_format": "dmg", + "default_categories": ["Browsers"] +} diff --git a/ee/maintained-apps/inputs/winget/firefox@developer-edition.json b/ee/maintained-apps/inputs/winget/firefox@developer-edition.json new file mode 100644 index 0000000000..21b9ead460 --- /dev/null +++ b/ee/maintained-apps/inputs/winget/firefox@developer-edition.json @@ -0,0 +1,12 @@ +{ + "name": "Mozilla Firefox Developer Edition", + "slug": "firefox@developer-edition/windows", + "package_identifier": "Mozilla.Firefox.DeveloperEdition", + "unique_identifier": "Firefox Developer Edition (x64 en-US)", + "install_script_path": "ee/maintained-apps/inputs/winget/scripts/firefox_developer_edition_install.ps1", + "uninstall_script_path": "ee/maintained-apps/inputs/winget/scripts/firefox_developer_edition_uninstall.ps1", + "installer_arch": "x64", + "installer_type": "exe", + "installer_scope": "machine", + "default_categories": ["Browsers"] +} diff --git a/ee/maintained-apps/inputs/winget/firefox@nightly.json b/ee/maintained-apps/inputs/winget/firefox@nightly.json new file mode 100644 index 0000000000..41d23febd5 --- /dev/null +++ b/ee/maintained-apps/inputs/winget/firefox@nightly.json @@ -0,0 +1,13 @@ +{ + "name": "Mozilla Firefox Nightly", + "slug": "firefox@nightly/windows", + "package_identifier": "Mozilla.Firefox.Nightly.MSIX", + "unique_identifier": "Firefox Nightly", + "program_publisher": "Mozilla Corporation", + "install_script_path": "ee/maintained-apps/inputs/winget/scripts/firefox_nightly_install.ps1", + "uninstall_script_path": "ee/maintained-apps/inputs/winget/scripts/firefox_nightly_uninstall.ps1", + "installer_arch": "x64", + "installer_type": "msix", + "installer_scope": "user", + "default_categories": ["Browsers"] +} diff --git a/ee/maintained-apps/inputs/winget/scripts/firefox_developer_edition_install.ps1 b/ee/maintained-apps/inputs/winget/scripts/firefox_developer_edition_install.ps1 new file mode 100644 index 0000000000..d2fd8d6a8d --- /dev/null +++ b/ee/maintained-apps/inputs/winget/scripts/firefox_developer_edition_install.ps1 @@ -0,0 +1,27 @@ +# Learn more about .exe install scripts: +# http://fleetdm.com/learn-more-about/exe-install-scripts + +$exeFilePath = "${env:INSTALLER_PATH}" + +try { + +# Firefox's full installer is NSIS-based; /S installs silently and machine-wide. +$processOptions = @{ + FilePath = "$exeFilePath" + ArgumentList = "/S" + PassThru = $true + Wait = $true +} + +# Start process and track exit code +$process = Start-Process @processOptions +$exitCode = $process.ExitCode + +# Prints the exit code +Write-Host "Install exit code: $exitCode" +Exit $exitCode + +} catch { + Write-Host "Error: $_" + Exit 1 +} diff --git a/ee/maintained-apps/inputs/winget/scripts/firefox_developer_edition_uninstall.ps1 b/ee/maintained-apps/inputs/winget/scripts/firefox_developer_edition_uninstall.ps1 new file mode 100644 index 0000000000..575d4f54e1 --- /dev/null +++ b/ee/maintained-apps/inputs/winget/scripts/firefox_developer_edition_uninstall.ps1 @@ -0,0 +1,90 @@ +# Fleet extracts name from installer (EXE) and saves it to PACKAGE_ID +# variable +$softwareName = "Firefox Developer Edition" + +# Developer Edition registers as "Firefox Developer Edition (x64 en-US)"; the +# prefix match cannot hit the release, ESR, or Nightly entries. +$softwareNameLike = "$softwareName*" + +# Firefox's NSIS uninstaller (helper.exe) runs silently with /S. +$uninstallArgs = "/S" + +$machineKey = ` + 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*' +$machineKey32on64 = ` + 'HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*' + +$exitCode = 0 + +try { + +[array]$uninstallKeys = Get-ChildItem ` + -Path @($machineKey, $machineKey32on64) ` + -ErrorAction SilentlyContinue | + ForEach-Object { Get-ItemProperty $_.PSPath } + +$foundUninstaller = $false +foreach ($key in $uninstallKeys) { + # If needed, add -notlike to the comparison to exclude certain similar + # software + if ($key.DisplayName -like $softwareNameLike) { + $foundUninstaller = $true + # Get the uninstall command. Some uninstallers do not include + # 'QuietUninstallString' and require a flag to run silently. + $uninstallCommand = if ($key.QuietUninstallString) { + $key.QuietUninstallString + } else { + $key.UninstallString + } + + # The uninstall command may contain command and args, like: + # "C:\Program Files\Software\uninstall.exe" --uninstall --silent + # Split the command and args + $splitArgs = $uninstallCommand.Split('"') + if ($splitArgs.Length -gt 1) { + if ($splitArgs.Length -eq 3) { + $uninstallArgs = "$( $splitArgs[2] ) $uninstallArgs".Trim() + } elseif ($splitArgs.Length -gt 3) { + Throw ` + "Uninstall command contains multiple quoted strings. " + + "Please update the uninstall script.`n" + + "Uninstall command: $uninstallCommand" + } + $uninstallCommand = $splitArgs[1] + } + Write-Host "Uninstall command: $uninstallCommand" + Write-Host "Uninstall args: $uninstallArgs" + + $processOptions = @{ + FilePath = $uninstallCommand + PassThru = $true + Wait = $true + } + if ($uninstallArgs -ne '') { + $processOptions.ArgumentList = "$uninstallArgs" + } + + # Start process and track exit code + $process = Start-Process @processOptions + $exitCode = $process.ExitCode + + # Prints the exit code + Write-Host "Uninstall exit code: $exitCode" + # Exit the loop once the software is found and uninstalled. + break + } +} + +if (-not $foundUninstaller) { + Write-Host "Uninstaller for '$softwareName' not found." + # Change exit code to 0 if you don't want to fail if uninstaller is not + # found. This could happen if program was already uninstalled. + $exitCode = 1 +} + +} catch { + Write-Host "Error: $_" + $exitCode = 1 +} + +Exit $exitCode diff --git a/ee/maintained-apps/inputs/winget/scripts/firefox_nightly_install.ps1 b/ee/maintained-apps/inputs/winget/scripts/firefox_nightly_install.ps1 new file mode 100644 index 0000000000..b66a74f26d --- /dev/null +++ b/ee/maintained-apps/inputs/winget/scripts/firefox_nightly_install.ps1 @@ -0,0 +1,99 @@ +# MSIX: provision machine-wide so the app is available to all users at sign-in, then +# opportunistically register for the currently logged-on console user (via a scheduled +# task in their session) so the app is immediately visible without requiring sign-out. +# +# The Fleet agent runs as Local System on Windows, and Add-AppxPackage cannot run in that +# context (HRESULT 0x80073CF9). The scheduled task is the supported way to register a +# package in a user session from a system-context script. + +$softwareName = "FirefoxNightly" +$taskName = "fleet-install-$softwareName.msix" +$scriptPath = "$env:PUBLIC\install-$softwareName.ps1" +$exitCodeFile = "$env:PUBLIC\install-exitcode-$softwareName.txt" + +try { + + $msixPath = $env:INSTALLER_PATH + if (-not $msixPath) { + throw "INSTALLER_PATH is not set" + } + + Write-Host "Provisioning MSIX for all users..." + $result = Add-AppxProvisionedPackage -Online -PackagePath $msixPath -SkipLicense -Regions "all" -ErrorAction Stop + $result | Out-String | Write-Host + + # Win32_ComputerSystem.UserName returns the console user (DOMAIN\User) or null when no + # interactive session is active. Other RDP/fast-user-switch sessions won't get the + # immediate registration; those users will pick it up from the provisioned install at + # their next sign-in. + $userName = (Get-CimInstance Win32_ComputerSystem).UserName + if (-not $userName -or $userName -notlike "*\*") { + Write-Host "No interactive user logged on; provisioned install will register for each user at sign-in." + Start-Sleep -Seconds 5 + Exit 0 + } + + Write-Host "Registering MSIX for logged-on user '$userName' via scheduled task..." + + $userScript = @" +`$msixPath = "$msixPath" +`$exitCodeFile = "$exitCodeFile" +try { + Add-AppxPackage -Path `$msixPath -ErrorAction Stop | Out-String | Write-Host + Set-Content -Path `$exitCodeFile -Value 0 +} catch { + Write-Host "Add-AppxPackage failed: `$(`$_.Exception.Message)" + Set-Content -Path `$exitCodeFile -Value 1 +} +"@ + + Set-Content -Path $scriptPath -Value $userScript -Force + + $action = New-ScheduledTaskAction -Execute "powershell.exe" ` + -Argument "-WindowStyle Hidden -ExecutionPolicy Bypass -File `"$scriptPath`"" + $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries + $principal = New-ScheduledTaskPrincipal -UserId $userName -RunLevel Highest + $task = New-ScheduledTask -Action $action -Settings $settings -Principal $principal + Register-ScheduledTask -TaskName $taskName -InputObject $task -User $userName -Force | Out-Null + Start-ScheduledTask -TaskName $taskName + + $startDate = Get-Date + $state = (Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue).State + while ($state -ne "Running") { + Start-Sleep -Seconds 1 + if ((New-Timespan -Start $startDate).TotalSeconds -gt 30) { + Write-Host "Per-user registration task did not start within 30s; provisioned install is still valid." + break + } + $state = (Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue).State + } + + while ($state -eq "Running") { + Start-Sleep -Seconds 2 + if ((New-Timespan -Start $startDate).TotalSeconds -gt 90) { + Write-Host "Per-user registration task did not complete within 90s; provisioned install is still valid." + break + } + $state = (Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue).State + } + + if (Test-Path $exitCodeFile) { + $code = (Get-Content $exitCodeFile -ErrorAction SilentlyContinue | Select-Object -First 1).Trim() + if ($code -eq "0") { + Write-Host "Per-user registration completed for '$userName'." + } else { + Write-Host "Per-user registration did not complete cleanly (exit code: $code). Provisioned install is still valid." + } + } + + Start-Sleep -Seconds 5 + Exit 0 + +} catch { + Write-Host "Error: $_" + Exit 1 +} finally { + Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue | Out-Null + Remove-Item -Path $scriptPath -Force -ErrorAction SilentlyContinue + Remove-Item -Path $exitCodeFile -Force -ErrorAction SilentlyContinue +} diff --git a/ee/maintained-apps/inputs/winget/scripts/firefox_nightly_uninstall.ps1 b/ee/maintained-apps/inputs/winget/scripts/firefox_nightly_uninstall.ps1 new file mode 100644 index 0000000000..fc677d11da --- /dev/null +++ b/ee/maintained-apps/inputs/winget/scripts/firefox_nightly_uninstall.ps1 @@ -0,0 +1,53 @@ +$timeoutSeconds = 300 # 5 minute timeout + +# Match only the Nightly channel: its MSIX identity "Mozilla.MozillaFirefoxNightly" +# cannot collide with other Firefox channels' identities. Don't match on a +# PackageFamilyName property: Get-AppxProvisionedPackage doesn't expose it, so an +# "-eq" match is $null for every package. +function ShouldRemoveFirefoxNightlyPackage { + param([Parameter(Mandatory=$true)]$pkg) + try { + $name = [string]$pkg.Name + $family = [string]$pkg.PackageFamilyName + + if ($name -and ($name -like "*MozillaFirefoxNightly*")) { return $true } + if ($family -and ($family -like "*MozillaFirefoxNightly*")) { return $true } + } catch {} + return $false +} + +try { + + $start = Get-Date + + $provisioned = Get-AppxProvisionedPackage -Online -ErrorAction Stop | Where-Object { + ($_.DisplayName -and ($_.DisplayName -like "*MozillaFirefoxNightly*")) -or + ($_.PackageName -and ($_.PackageName -like "*MozillaFirefoxNightly*")) + } + foreach ($pkg in $provisioned) { + Write-Host "Removing provisioned package: $($pkg.PackageName)" + Remove-AppxProvisionedPackage -Online -PackageName $pkg.PackageName -AllUsers -ErrorAction Stop | Out-String | Write-Host + $elapsed = (New-TimeSpan -Start $start).TotalSeconds + if ($elapsed -gt $timeoutSeconds) { + Exit 1603 + } + } + + $installed = Get-AppxPackage -AllUsers -PackageTypeFilter Main -ErrorAction SilentlyContinue | Where-Object { + ShouldRemoveFirefoxNightlyPackage $_ + } + foreach ($app in $installed) { + Write-Host "Removing installed package: $($app.PackageFullName)" + Remove-AppxPackage -Package $app.PackageFullName -AllUsers -ErrorAction Stop | Out-String | Write-Host + $elapsed = (New-TimeSpan -Start $start).TotalSeconds + if ($elapsed -gt $timeoutSeconds) { + Exit 1603 + } + } + + Exit 0 + +} catch { + Write-Host "Error: $_" + Exit 1603 +} diff --git a/ee/maintained-apps/inputs/winget/scripts/firefox_uninstall.ps1 b/ee/maintained-apps/inputs/winget/scripts/firefox_uninstall.ps1 index 9d4e58e2d7..3e8b0f2a95 100644 --- a/ee/maintained-apps/inputs/winget/scripts/firefox_uninstall.ps1 +++ b/ee/maintained-apps/inputs/winget/scripts/firefox_uninstall.ps1 @@ -1,10 +1,11 @@ # Fleet extracts name from installer (EXE) and saves it to PACKAGE_ID # variable -$softwareName = "Firefox" +$softwareName = "Mozilla Firefox" -# It is recommended to use exact software name here if possible to avoid -# uninstalling unintended software. -$softwareNameLike = "*$softwareName*" +# Match only the release channel ("Mozilla Firefox (x64 en-US)"); the prefix +# match plus the ESR exclusion below keeps ESR, Developer Edition, and Nightly +# entries untouched. +$softwareNameLike = "$softwareName*" # Some uninstallers require a flag to run silently. # Each uninstaller might use different argument (usually it's "/S" or "/s") @@ -28,7 +29,8 @@ $foundUninstaller = $false foreach ($key in $uninstallKeys) { # If needed, add -notlike to the comparison to exclude certain similar # software - if ($key.DisplayName -like $softwareNameLike) { + if ($key.DisplayName -like $softwareNameLike -and + $key.DisplayName -notlike "*ESR*") { $foundUninstaller = $true # Get the uninstall command. Some uninstallers do not include # 'QuietUninstallString' and require a flag to run silently. diff --git a/ee/maintained-apps/outputs/apps.json b/ee/maintained-apps/outputs/apps.json index cf93d335b4..84cae33ea7 100644 --- a/ee/maintained-apps/outputs/apps.json +++ b/ee/maintained-apps/outputs/apps.json @@ -3277,6 +3277,20 @@ "unique_identifier": "Mozilla Firefox (x64 en-US)", "description": "Firefox is a powerful, open-source web browser built for speed, privacy, and customization." }, + { + "name": "Mozilla Firefox Developer Edition", + "slug": "firefox@developer-edition/darwin", + "platform": "darwin", + "unique_identifier": "org.mozilla.firefoxdeveloperedition", + "description": "Mozilla Firefox Developer Edition is the version of the Firefox web browser made for web developers, with cutting-edge features and built-in developer tools." + }, + { + "name": "Mozilla Firefox Developer Edition", + "slug": "firefox@developer-edition/windows", + "platform": "windows", + "unique_identifier": "Firefox Developer Edition (x64 en-US)", + "description": "Mozilla Firefox Developer Edition is the version of the Firefox web browser made for web developers, with cutting-edge features and built-in developer tools." + }, { "name": "Mozilla Firefox ESR", "slug": "firefox@esr/darwin", @@ -3291,6 +3305,20 @@ "unique_identifier": "Mozilla Firefox 140.7.1 ESR (x64 en-US)", "description": "Mozilla Firefox ESR is the Extended Support Release version of the popular web browser Firefox." }, + { + "name": "Mozilla Firefox Nightly", + "slug": "firefox@nightly/darwin", + "platform": "darwin", + "unique_identifier": "org.mozilla.nightly", + "description": "Mozilla Firefox Nightly is the daily development build of the Firefox web browser, with the newest features before they reach beta and release." + }, + { + "name": "Mozilla Firefox Nightly", + "slug": "firefox@nightly/windows", + "platform": "windows", + "unique_identifier": "Firefox Nightly", + "description": "Mozilla Firefox Nightly is the daily development build of the Firefox web browser, with the newest features before they reach beta and release." + }, { "name": "Fission", "slug": "fission/darwin", diff --git a/ee/maintained-apps/outputs/firefox/windows.json b/ee/maintained-apps/outputs/firefox/windows.json index bcaad3ad25..bc4d18f5c0 100644 --- a/ee/maintained-apps/outputs/firefox/windows.json +++ b/ee/maintained-apps/outputs/firefox/windows.json @@ -8,7 +8,7 @@ }, "installer_url": "https://download-installer.cdn.mozilla.net/pub/firefox/releases/152.0.6/win64/en-US/Firefox%20Setup%20152.0.6.exe", "install_script_ref": "80fb9175", - "uninstall_script_ref": "8b5e20e4", + "uninstall_script_ref": "ae547434", "sha256": "3d4fcc5370bb183c9535d64b98d946c2dacf664a394ebcc02844e361d58d59b4", "default_categories": [ "Browsers" @@ -17,6 +17,6 @@ ], "refs": { "80fb9175": "# Learn more about .exe install scripts:\n# http://fleetdm.com/learn-more-about/exe-install-scripts\n\n$exeFilePath = \"${env:INSTALLER_PATH}\"\n\ntry {\n\n# Add argument to install silently\n# Argument to make install silent depends on installer,\n# each installer might use different argument (usually it's \"/S\" or \"/s\")\n$processOptions = @{\n FilePath = \"$exeFilePath\"\n ArgumentList = \"/S\"\n PassThru = $true\n Wait = $true\n}\n \n# Start process and track exit code\n$process = Start-Process @processOptions\n$exitCode = $process.ExitCode\n\n# Prints the exit code\nWrite-Host \"Install exit code: $exitCode\"\nExit $exitCode\n\n} catch {\n Write-Host \"Error: $_\"\n Exit 1\n}\n# Learn more about .exe install scripts:\n# http://fleetdm.com/learn-more-about/exe-install-scripts\n\n$exeFilePath = \"${env:INSTALLER_PATH}\"\n\ntry {\n\n# Add arguments to install silently (Firefox uses an Inno Setup-based installer)\n$processOptions = @{\n FilePath = \"$exeFilePath\"\n ArgumentList = \"/SP- /VERYSILENT /SUPPRESSMSGBOXES /NORESTART /CLOSEAPPLICATIONS /MERGETASKS=!runcode\"\n PassThru = $true\n Wait = $true\n}\n \n# Start process and track exit code\n$process = Start-Process @processOptions\n$exitCode = $process.ExitCode\n\n# Prints the exit code\nWrite-Host \"Install exit code: $exitCode\"\nExit $exitCode\n\n} catch {\n Write-Host \"Error: $_\"\n Exit 1\n}\n", - "8b5e20e4": "# Fleet extracts name from installer (EXE) and saves it to PACKAGE_ID\n# variable\n$softwareName = \"Firefox\"\n\n# It is recommended to use exact software name here if possible to avoid\n# uninstalling unintended software.\n$softwareNameLike = \"*$softwareName*\"\n\n# Some uninstallers require a flag to run silently.\n# Each uninstaller might use different argument (usually it's \"/S\" or \"/s\")\n$uninstallArgs = \"/S\"\n\n$machineKey = `\n 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*'\n$machineKey32on64 = `\n 'HKLM:\\SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*'\n\n$exitCode = 0\n\ntry {\n\n[array]$uninstallKeys = Get-ChildItem `\n -Path @($machineKey, $machineKey32on64) `\n -ErrorAction SilentlyContinue |\n ForEach-Object { Get-ItemProperty $_.PSPath }\n\n$foundUninstaller = $false\nforeach ($key in $uninstallKeys) {\n # If needed, add -notlike to the comparison to exclude certain similar\n # software\n if ($key.DisplayName -like $softwareNameLike) {\n $foundUninstaller = $true\n # Get the uninstall command. Some uninstallers do not include\n # 'QuietUninstallString' and require a flag to run silently.\n $uninstallCommand = if ($key.QuietUninstallString) {\n $key.QuietUninstallString\n } else {\n $key.UninstallString\n }\n\n # The uninstall command may contain command and args, like:\n # \"C:\\Program Files\\Software\\uninstall.exe\" --uninstall --silent\n # Split the command and args\n $splitArgs = $uninstallCommand.Split('\"')\n if ($splitArgs.Length -gt 1) {\n if ($splitArgs.Length -eq 3) {\n $uninstallArgs = \"$( $splitArgs[2] ) $uninstallArgs\".Trim()\n } elseif ($splitArgs.Length -gt 3) {\n Throw `\n \"Uninstall command contains multiple quoted strings. \" +\n \"Please update the uninstall script.`n\" +\n \"Uninstall command: $uninstallCommand\"\n }\n $uninstallCommand = $splitArgs[1]\n }\n Write-Host \"Uninstall command: $uninstallCommand\"\n Write-Host \"Uninstall args: $uninstallArgs\"\n\n $processOptions = @{\n FilePath = $uninstallCommand\n PassThru = $true\n Wait = $true\n }\n if ($uninstallArgs -ne '') {\n $processOptions.ArgumentList = \"$uninstallArgs\"\n }\n\n # Start process and track exit code\n $process = Start-Process @processOptions\n $exitCode = $process.ExitCode\n\n # Prints the exit code\n Write-Host \"Uninstall exit code: $exitCode\"\n # Exit the loop once the software is found and uninstalled.\n break\n }\n}\n\nif (-not $foundUninstaller) {\n Write-Host \"Uninstaller for '$softwareName' not found.\"\n # Change exit code to 0 if you don't want to fail if uninstaller is not\n # found. This could happen if program was already uninstalled.\n $exitCode = 1\n}\n\n} catch {\n Write-Host \"Error: $_\"\n $exitCode = 1\n}\n\nExit $exitCode\n" + "ae547434": "# Fleet extracts name from installer (EXE) and saves it to PACKAGE_ID\n# variable\n$softwareName = \"Mozilla Firefox\"\n\n# Match only the release channel (\"Mozilla Firefox (x64 en-US)\"); the prefix\n# match plus the ESR exclusion below keeps ESR, Developer Edition, and Nightly\n# entries untouched.\n$softwareNameLike = \"$softwareName*\"\n\n# Some uninstallers require a flag to run silently.\n# Each uninstaller might use different argument (usually it's \"/S\" or \"/s\")\n$uninstallArgs = \"/S\"\n\n$machineKey = `\n 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*'\n$machineKey32on64 = `\n 'HKLM:\\SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*'\n\n$exitCode = 0\n\ntry {\n\n[array]$uninstallKeys = Get-ChildItem `\n -Path @($machineKey, $machineKey32on64) `\n -ErrorAction SilentlyContinue |\n ForEach-Object { Get-ItemProperty $_.PSPath }\n\n$foundUninstaller = $false\nforeach ($key in $uninstallKeys) {\n # If needed, add -notlike to the comparison to exclude certain similar\n # software\n if ($key.DisplayName -like $softwareNameLike -and\n $key.DisplayName -notlike \"*ESR*\") {\n $foundUninstaller = $true\n # Get the uninstall command. Some uninstallers do not include\n # 'QuietUninstallString' and require a flag to run silently.\n $uninstallCommand = if ($key.QuietUninstallString) {\n $key.QuietUninstallString\n } else {\n $key.UninstallString\n }\n\n # The uninstall command may contain command and args, like:\n # \"C:\\Program Files\\Software\\uninstall.exe\" --uninstall --silent\n # Split the command and args\n $splitArgs = $uninstallCommand.Split('\"')\n if ($splitArgs.Length -gt 1) {\n if ($splitArgs.Length -eq 3) {\n $uninstallArgs = \"$( $splitArgs[2] ) $uninstallArgs\".Trim()\n } elseif ($splitArgs.Length -gt 3) {\n Throw `\n \"Uninstall command contains multiple quoted strings. \" +\n \"Please update the uninstall script.`n\" +\n \"Uninstall command: $uninstallCommand\"\n }\n $uninstallCommand = $splitArgs[1]\n }\n Write-Host \"Uninstall command: $uninstallCommand\"\n Write-Host \"Uninstall args: $uninstallArgs\"\n\n $processOptions = @{\n FilePath = $uninstallCommand\n PassThru = $true\n Wait = $true\n }\n if ($uninstallArgs -ne '') {\n $processOptions.ArgumentList = \"$uninstallArgs\"\n }\n\n # Start process and track exit code\n $process = Start-Process @processOptions\n $exitCode = $process.ExitCode\n\n # Prints the exit code\n Write-Host \"Uninstall exit code: $exitCode\"\n # Exit the loop once the software is found and uninstalled.\n break\n }\n}\n\nif (-not $foundUninstaller) {\n Write-Host \"Uninstaller for '$softwareName' not found.\"\n # Change exit code to 0 if you don't want to fail if uninstaller is not\n # found. This could happen if program was already uninstalled.\n $exitCode = 1\n}\n\n} catch {\n Write-Host \"Error: $_\"\n $exitCode = 1\n}\n\nExit $exitCode\n" } } diff --git a/ee/maintained-apps/outputs/firefox@developer-edition/darwin.json b/ee/maintained-apps/outputs/firefox@developer-edition/darwin.json new file mode 100644 index 0000000000..7b6e8a5bec --- /dev/null +++ b/ee/maintained-apps/outputs/firefox@developer-edition/darwin.json @@ -0,0 +1,22 @@ +{ + "versions": [ + { + "version": "153.0b13", + "queries": { + "exists": "SELECT 1 FROM apps WHERE bundle_identifier = 'org.mozilla.firefoxdeveloperedition';", + "patched": "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM apps WHERE bundle_identifier = 'org.mozilla.firefoxdeveloperedition' AND version_compare(bundle_version, '15326.7.15') < 0);" + }, + "installer_url": "https://download-installer.cdn.mozilla.net/pub/devedition/releases/153.0b13/mac/en-US/Firefox%20153.0b13.dmg", + "install_script_ref": "a313f903", + "uninstall_script_ref": "b108b0ff", + "sha256": "6d30d4c74a97ed2e0fea2956006a1e0d589a856061291eb8bbde95378cc806fd", + "default_categories": [ + "Browsers" + ] + } + ], + "refs": { + "a313f903": "#!/bin/bash\n\n# variables\nAPPDIR=\"/Applications/\"\nTMPDIR=$(dirname \"$(realpath \"$INSTALLER_PATH\")\")\n# functions\n\nquit_and_track_application() {\n local bundle_id=\"$1\"\n local var_name=\"APP_WAS_RUNNING_$(echo \"$bundle_id\" | tr '.-' '__')\"\n local timeout_duration=10\n\n # check if the application is running\n local app_running\n app_running=$(osascript -e \"application id \\\"$bundle_id\\\" is running\" 2>/dev/null)\n if [[ \"$app_running\" != \"true\" ]]; then\n eval \"export $var_name=0\"\n return\n fi\n\n local console_user\n console_user=$(stat -f \"%Su\" /dev/console)\n if [[ -z \"$console_user\" || \"$console_user\" == \"root\" || \"$console_user\" == \"loginwindow\" ]]; then\n echo \"Not logged into a non-root GUI; skipping quitting application ID '$bundle_id'.\"\n eval \"export $var_name=0\"\n return\n fi\n\n # App was running, mark it for relaunch\n eval \"export $var_name=1\"\n echo \"Application '$bundle_id' was running; will relaunch after installation.\"\n\n echo \"Quitting application '$bundle_id'...\"\n\n # try to quit the application within the timeout period\n local quit_success=false\n SECONDS=0\n while (( SECONDS < timeout_duration )); do\n if osascript -e \"tell application id \\\"$bundle_id\\\" to quit\" >/dev/null 2>&1; then\n if ! pgrep -f \"$bundle_id\" >/dev/null 2>&1; then\n echo \"Application '$bundle_id' quit successfully.\"\n quit_success=true\n break\n fi\n fi\n sleep 1\n done\n\n if [[ \"$quit_success\" = false ]]; then\n echo \"Application '$bundle_id' did not quit.\"\n fi\n}\n\n\nrelaunch_application() {\n local bundle_id=\"$1\"\n local var_name=\"APP_WAS_RUNNING_$(echo \"$bundle_id\" | tr '.-' '__')\"\n local was_running\n\n # Check if the app was running before installation\n eval \"was_running=\\$$var_name\"\n if [[ \"$was_running\" != \"1\" ]]; then\n return\n fi\n\n local console_user\n console_user=$(stat -f \"%Su\" /dev/console)\n if [[ -z \"$console_user\" || \"$console_user\" == \"root\" || \"$console_user\" == \"loginwindow\" ]]; then\n echo \"Not logged into a non-root GUI; skipping relaunching application ID '$bundle_id'.\"\n return\n fi\n\n echo \"Relaunching application '$bundle_id'...\"\n\n # Launch the app in the logged-in user's GUI session. Apps launched by root\n # won't register with the user's Dock/GUI, so run 'open' as the console user.\n # Use 'launchctl asuser' to bootstrap into the console user's Mach namespace\n # and GUI session — 'sudo -u' alone doesn't do this, which can cause\n # LSOpenURLsWithRole() failures even when 'open' exits 0.\n local open_status=0\n if [[ $EUID -eq 0 ]]; then\n local console_uid\n console_uid=$(id -u \"$console_user\")\n /bin/launchctl asuser \"$console_uid\" sudo -u \"$console_user\" open -b \"$bundle_id\" >/dev/null 2>&1 || open_status=$?\n else\n open -b \"$bundle_id\" >/dev/null 2>&1 || open_status=$?\n fi\n\n if [[ $open_status -eq 0 ]]; then\n echo \"Application '$bundle_id' relaunched successfully.\"\n else\n echo \"Failed to relaunch application '$bundle_id'.\"\n fi\n}\n\n\n# extract contents\nMOUNT_POINT=$(mktemp -d /tmp/dmg_mount_XXXXXX)\nyes | hdiutil attach -plist -nobrowse -readonly -mountpoint \"$MOUNT_POINT\" \"$INSTALLER_PATH\" || exit 1\nsudo cp -R \"$MOUNT_POINT\"/* \"$TMPDIR\"\nhdiutil detach \"$MOUNT_POINT\" || true\n# copy to the applications folder\nquit_and_track_application 'org.mozilla.firefoxdeveloperedition'\nif [ -d \"$APPDIR/Firefox Developer Edition.app\" ]; then\n\tsudo mv \"$APPDIR/Firefox Developer Edition.app\" \"$TMPDIR/Firefox Developer Edition.app.bkp\"\nfi\nsudo cp -R \"$TMPDIR/Firefox Developer Edition.app\" \"$APPDIR\"\nrelaunch_application 'org.mozilla.firefoxdeveloperedition'\n", + "b108b0ff": "#!/bin/bash\n\n# variables\nAPPDIR=\"/Applications/\"\nLOGGED_IN_USER=$(scutil <<< \"show State:/Users/ConsoleUser\" | awk '/Name :/ { print $3 }')\n# functions\n\ntrash() {\n local logged_in_user=\"$1\"\n local target_file=\"$2\"\n local timestamp=\"$(date +%Y-%m-%d-%s)\"\n local rand=\"$(jot -r 1 0 99999)\"\n\n # replace ~ with /Users/$logged_in_user\n if [[ \"$target_file\" == ~* ]]; then\n target_file=\"/Users/$logged_in_user${target_file:1}\"\n fi\n\n local trash=\"/Users/$logged_in_user/.Trash\"\n\n # If the target contains glob characters, expand it and move each match.\n if [[ \"$target_file\" == *[*?[]* ]]; then\n local file file_name\n local matched=false\n local i=0\n # compgen -G expands the (quoted) pattern itself, so paths containing\n # spaces glob correctly; reading line by line keeps each match intact.\n while IFS= read -r file; do\n [[ -n \"$file\" ]] || continue\n [[ -e \"$file\" || -L \"$file\" ]] || continue\n matched=true\n i=$((i + 1))\n file_name=\"$(basename \"$file\")\"\n echo \"removing $file.\"\n # The per-match counter keeps matches that share a basename from\n # overwriting each other in the trash.\n mv -f \"$file\" \"$trash/${file_name}_${timestamp}_${rand}_${i}\"\n done < <(compgen -G \"$target_file\" 2>/dev/null)\n if [[ \"$matched\" == false ]]; then\n echo \"$target_file doesn't exist.\"\n fi\n return\n fi\n\n local file_name=\"$(basename \"${target_file}\")\"\n\n if [[ -e \"$target_file\" ]]; then\n echo \"removing $target_file.\"\n mv -f \"$target_file\" \"$trash/${file_name}_${timestamp}_${rand}\"\n else\n echo \"$target_file doesn't exist.\"\n fi\n}\n\nsudo rm -rf \"$APPDIR/Firefox Developer Edition.app\"\nsudo rmdir '~/Library/Application Support/Mozilla'\nsudo rmdir '~/Library/Caches/Mozilla'\nsudo rmdir '~/Library/Caches/Mozilla/updates'\nsudo rmdir '~/Library/Caches/Mozilla/updates/Applications'\ntrash $LOGGED_IN_USER '/Library/Logs/DiagnosticReports/firefox_*'\ntrash $LOGGED_IN_USER '~/Library/Application Support/com.apple.sharedfilelist/com.apple.LSSharedFileList.ApplicationRecentDocuments/org.mozilla.firefox.sfl*'\ntrash $LOGGED_IN_USER '~/Library/Application Support/CrashReporter/firefox_*'\ntrash $LOGGED_IN_USER '~/Library/Application Support/Firefox'\ntrash $LOGGED_IN_USER '~/Library/Caches/Firefox'\ntrash $LOGGED_IN_USER '~/Library/Caches/Mozilla/updates/Applications/Firefox'\ntrash $LOGGED_IN_USER '~/Library/Caches/org.mozilla.firefox'\ntrash $LOGGED_IN_USER '~/Library/Preferences/org.mozilla.firefox.plist'\ntrash $LOGGED_IN_USER '~/Library/Saved Application State/org.mozilla.firefox.savedState'\ntrash $LOGGED_IN_USER '~/Library/WebKit/org.mozilla.firefox'\n" + } +} diff --git a/ee/maintained-apps/outputs/firefox@developer-edition/windows.json b/ee/maintained-apps/outputs/firefox@developer-edition/windows.json new file mode 100644 index 0000000000..a0149f43df --- /dev/null +++ b/ee/maintained-apps/outputs/firefox@developer-edition/windows.json @@ -0,0 +1,22 @@ +{ + "versions": [ + { + "version": "151.0", + "queries": { + "exists": "SELECT 1 FROM programs WHERE name = 'Firefox Developer Edition (x64 en-US)' AND publisher = 'Mozilla';", + "patched": "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM programs WHERE name = 'Firefox Developer Edition (x64 en-US)' AND publisher = 'Mozilla' AND version_compare(version, '151.0') < 0);" + }, + "installer_url": "https://download-installer.cdn.mozilla.net/pub/devedition/releases/151.0b10/win64/en-US/Firefox%20Setup%20151.0b10.exe", + "install_script_ref": "30fd5964", + "uninstall_script_ref": "cc59b3f5", + "sha256": "ef21f97a29de39f55882368e22189c32c289518e9063acf89d1eb28b12f31023", + "default_categories": [ + "Browsers" + ] + } + ], + "refs": { + "30fd5964": "# Learn more about .exe install scripts:\n# http://fleetdm.com/learn-more-about/exe-install-scripts\n\n$exeFilePath = \"${env:INSTALLER_PATH}\"\n\ntry {\n\n# Firefox's full installer is NSIS-based; /S installs silently and machine-wide.\n$processOptions = @{\n FilePath = \"$exeFilePath\"\n ArgumentList = \"/S\"\n PassThru = $true\n Wait = $true\n}\n\n# Start process and track exit code\n$process = Start-Process @processOptions\n$exitCode = $process.ExitCode\n\n# Prints the exit code\nWrite-Host \"Install exit code: $exitCode\"\nExit $exitCode\n\n} catch {\n Write-Host \"Error: $_\"\n Exit 1\n}\n", + "cc59b3f5": "# Fleet extracts name from installer (EXE) and saves it to PACKAGE_ID\n# variable\n$softwareName = \"Firefox Developer Edition\"\n\n# Developer Edition registers as \"Firefox Developer Edition (x64 en-US)\"; the\n# prefix match cannot hit the release, ESR, or Nightly entries.\n$softwareNameLike = \"$softwareName*\"\n\n# Firefox's NSIS uninstaller (helper.exe) runs silently with /S.\n$uninstallArgs = \"/S\"\n\n$machineKey = `\n 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*'\n$machineKey32on64 = `\n 'HKLM:\\SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*'\n\n$exitCode = 0\n\ntry {\n\n[array]$uninstallKeys = Get-ChildItem `\n -Path @($machineKey, $machineKey32on64) `\n -ErrorAction SilentlyContinue |\n ForEach-Object { Get-ItemProperty $_.PSPath }\n\n$foundUninstaller = $false\nforeach ($key in $uninstallKeys) {\n # If needed, add -notlike to the comparison to exclude certain similar\n # software\n if ($key.DisplayName -like $softwareNameLike) {\n $foundUninstaller = $true\n # Get the uninstall command. Some uninstallers do not include\n # 'QuietUninstallString' and require a flag to run silently.\n $uninstallCommand = if ($key.QuietUninstallString) {\n $key.QuietUninstallString\n } else {\n $key.UninstallString\n }\n\n # The uninstall command may contain command and args, like:\n # \"C:\\Program Files\\Software\\uninstall.exe\" --uninstall --silent\n # Split the command and args\n $splitArgs = $uninstallCommand.Split('\"')\n if ($splitArgs.Length -gt 1) {\n if ($splitArgs.Length -eq 3) {\n $uninstallArgs = \"$( $splitArgs[2] ) $uninstallArgs\".Trim()\n } elseif ($splitArgs.Length -gt 3) {\n Throw `\n \"Uninstall command contains multiple quoted strings. \" +\n \"Please update the uninstall script.`n\" +\n \"Uninstall command: $uninstallCommand\"\n }\n $uninstallCommand = $splitArgs[1]\n }\n Write-Host \"Uninstall command: $uninstallCommand\"\n Write-Host \"Uninstall args: $uninstallArgs\"\n\n $processOptions = @{\n FilePath = $uninstallCommand\n PassThru = $true\n Wait = $true\n }\n if ($uninstallArgs -ne '') {\n $processOptions.ArgumentList = \"$uninstallArgs\"\n }\n\n # Start process and track exit code\n $process = Start-Process @processOptions\n $exitCode = $process.ExitCode\n\n # Prints the exit code\n Write-Host \"Uninstall exit code: $exitCode\"\n # Exit the loop once the software is found and uninstalled.\n break\n }\n}\n\nif (-not $foundUninstaller) {\n Write-Host \"Uninstaller for '$softwareName' not found.\"\n # Change exit code to 0 if you don't want to fail if uninstaller is not\n # found. This could happen if program was already uninstalled.\n $exitCode = 1\n}\n\n} catch {\n Write-Host \"Error: $_\"\n $exitCode = 1\n}\n\nExit $exitCode\n" + } +} diff --git a/ee/maintained-apps/outputs/firefox@nightly/darwin.json b/ee/maintained-apps/outputs/firefox@nightly/darwin.json new file mode 100644 index 0000000000..e282e87d90 --- /dev/null +++ b/ee/maintained-apps/outputs/firefox@nightly/darwin.json @@ -0,0 +1,22 @@ +{ + "versions": [ + { + "version": "154.0a1", + "queries": { + "exists": "SELECT 1 FROM apps WHERE bundle_identifier = 'org.mozilla.nightly';", + "patched": "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM apps WHERE bundle_identifier = 'org.mozilla.nightly' AND version_compare(bundle_version, '15426.7.17') < 0);" + }, + "installer_url": "https://ftp.mozilla.org/pub/firefox/nightly/2026/07/2026-07-17-09-27-13-mozilla-central/firefox-154.0a1.en-US.mac.dmg", + "install_script_ref": "418c9331", + "uninstall_script_ref": "d7c711ad", + "sha256": "d2e4b8ce0eb19a9d5c99c86bf0b0c660130e4b3357e056da92844c3ce47bb982", + "default_categories": [ + "Browsers" + ] + } + ], + "refs": { + "418c9331": "#!/bin/bash\n\n# variables\nAPPDIR=\"/Applications/\"\nTMPDIR=$(dirname \"$(realpath \"$INSTALLER_PATH\")\")\n# functions\n\nquit_and_track_application() {\n local bundle_id=\"$1\"\n local var_name=\"APP_WAS_RUNNING_$(echo \"$bundle_id\" | tr '.-' '__')\"\n local timeout_duration=10\n\n # check if the application is running\n local app_running\n app_running=$(osascript -e \"application id \\\"$bundle_id\\\" is running\" 2>/dev/null)\n if [[ \"$app_running\" != \"true\" ]]; then\n eval \"export $var_name=0\"\n return\n fi\n\n local console_user\n console_user=$(stat -f \"%Su\" /dev/console)\n if [[ -z \"$console_user\" || \"$console_user\" == \"root\" || \"$console_user\" == \"loginwindow\" ]]; then\n echo \"Not logged into a non-root GUI; skipping quitting application ID '$bundle_id'.\"\n eval \"export $var_name=0\"\n return\n fi\n\n # App was running, mark it for relaunch\n eval \"export $var_name=1\"\n echo \"Application '$bundle_id' was running; will relaunch after installation.\"\n\n echo \"Quitting application '$bundle_id'...\"\n\n # try to quit the application within the timeout period\n local quit_success=false\n SECONDS=0\n while (( SECONDS < timeout_duration )); do\n if osascript -e \"tell application id \\\"$bundle_id\\\" to quit\" >/dev/null 2>&1; then\n if ! pgrep -f \"$bundle_id\" >/dev/null 2>&1; then\n echo \"Application '$bundle_id' quit successfully.\"\n quit_success=true\n break\n fi\n fi\n sleep 1\n done\n\n if [[ \"$quit_success\" = false ]]; then\n echo \"Application '$bundle_id' did not quit.\"\n fi\n}\n\n\nrelaunch_application() {\n local bundle_id=\"$1\"\n local var_name=\"APP_WAS_RUNNING_$(echo \"$bundle_id\" | tr '.-' '__')\"\n local was_running\n\n # Check if the app was running before installation\n eval \"was_running=\\$$var_name\"\n if [[ \"$was_running\" != \"1\" ]]; then\n return\n fi\n\n local console_user\n console_user=$(stat -f \"%Su\" /dev/console)\n if [[ -z \"$console_user\" || \"$console_user\" == \"root\" || \"$console_user\" == \"loginwindow\" ]]; then\n echo \"Not logged into a non-root GUI; skipping relaunching application ID '$bundle_id'.\"\n return\n fi\n\n echo \"Relaunching application '$bundle_id'...\"\n\n # Launch the app in the logged-in user's GUI session. Apps launched by root\n # won't register with the user's Dock/GUI, so run 'open' as the console user.\n # Use 'launchctl asuser' to bootstrap into the console user's Mach namespace\n # and GUI session — 'sudo -u' alone doesn't do this, which can cause\n # LSOpenURLsWithRole() failures even when 'open' exits 0.\n local open_status=0\n if [[ $EUID -eq 0 ]]; then\n local console_uid\n console_uid=$(id -u \"$console_user\")\n /bin/launchctl asuser \"$console_uid\" sudo -u \"$console_user\" open -b \"$bundle_id\" >/dev/null 2>&1 || open_status=$?\n else\n open -b \"$bundle_id\" >/dev/null 2>&1 || open_status=$?\n fi\n\n if [[ $open_status -eq 0 ]]; then\n echo \"Application '$bundle_id' relaunched successfully.\"\n else\n echo \"Failed to relaunch application '$bundle_id'.\"\n fi\n}\n\n\n# extract contents\nMOUNT_POINT=$(mktemp -d /tmp/dmg_mount_XXXXXX)\nyes | hdiutil attach -plist -nobrowse -readonly -mountpoint \"$MOUNT_POINT\" \"$INSTALLER_PATH\" || exit 1\nsudo cp -R \"$MOUNT_POINT\"/* \"$TMPDIR\"\nhdiutil detach \"$MOUNT_POINT\" || true\n# copy to the applications folder\nquit_and_track_application 'org.mozilla.nightly'\nif [ -d \"$APPDIR/Firefox Nightly.app\" ]; then\n\tsudo mv \"$APPDIR/Firefox Nightly.app\" \"$TMPDIR/Firefox Nightly.app.bkp\"\nfi\nsudo cp -R \"$TMPDIR/Firefox Nightly.app\" \"$APPDIR\"\nrelaunch_application 'org.mozilla.nightly'\n", + "d7c711ad": "#!/bin/bash\n\n# variables\nAPPDIR=\"/Applications/\"\nLOGGED_IN_USER=$(scutil <<< \"show State:/Users/ConsoleUser\" | awk '/Name :/ { print $3 }')\n# functions\n\ntrash() {\n local logged_in_user=\"$1\"\n local target_file=\"$2\"\n local timestamp=\"$(date +%Y-%m-%d-%s)\"\n local rand=\"$(jot -r 1 0 99999)\"\n\n # replace ~ with /Users/$logged_in_user\n if [[ \"$target_file\" == ~* ]]; then\n target_file=\"/Users/$logged_in_user${target_file:1}\"\n fi\n\n local trash=\"/Users/$logged_in_user/.Trash\"\n\n # If the target contains glob characters, expand it and move each match.\n if [[ \"$target_file\" == *[*?[]* ]]; then\n local file file_name\n local matched=false\n local i=0\n # compgen -G expands the (quoted) pattern itself, so paths containing\n # spaces glob correctly; reading line by line keeps each match intact.\n while IFS= read -r file; do\n [[ -n \"$file\" ]] || continue\n [[ -e \"$file\" || -L \"$file\" ]] || continue\n matched=true\n i=$((i + 1))\n file_name=\"$(basename \"$file\")\"\n echo \"removing $file.\"\n # The per-match counter keeps matches that share a basename from\n # overwriting each other in the trash.\n mv -f \"$file\" \"$trash/${file_name}_${timestamp}_${rand}_${i}\"\n done < <(compgen -G \"$target_file\" 2>/dev/null)\n if [[ \"$matched\" == false ]]; then\n echo \"$target_file doesn't exist.\"\n fi\n return\n fi\n\n local file_name=\"$(basename \"${target_file}\")\"\n\n if [[ -e \"$target_file\" ]]; then\n echo \"removing $target_file.\"\n mv -f \"$target_file\" \"$trash/${file_name}_${timestamp}_${rand}\"\n else\n echo \"$target_file doesn't exist.\"\n fi\n}\n\nsudo rm -rf \"$APPDIR/Firefox Nightly.app\"\nsudo rmdir '~/Library/Application Support/Mozilla'\nsudo rmdir '~/Library/Caches/Mozilla'\nsudo rmdir '~/Library/Caches/Mozilla/updates'\nsudo rmdir '~/Library/Caches/Mozilla/updates/Applications'\ntrash $LOGGED_IN_USER '/Library/Logs/DiagnosticReports/firefox_*'\ntrash $LOGGED_IN_USER '~/Library/Application Support/com.apple.sharedfilelist/com.apple.LSSharedFileList.ApplicationRecentDocuments/org.mozilla.firefox.sfl*'\ntrash $LOGGED_IN_USER '~/Library/Application Support/CrashReporter/firefox_*'\ntrash $LOGGED_IN_USER '~/Library/Application Support/Firefox'\ntrash $LOGGED_IN_USER '~/Library/Caches/Firefox'\ntrash $LOGGED_IN_USER '~/Library/Caches/Mozilla/updates/Applications/Firefox'\ntrash $LOGGED_IN_USER '~/Library/Caches/org.mozilla.firefox'\ntrash $LOGGED_IN_USER '~/Library/Preferences/org.mozilla.firefox.plist'\ntrash $LOGGED_IN_USER '~/Library/Saved Application State/org.mozilla.firefox.savedState'\ntrash $LOGGED_IN_USER '~/Library/WebKit/org.mozilla.firefox'\n" + } +} diff --git a/ee/maintained-apps/outputs/firefox@nightly/windows.json b/ee/maintained-apps/outputs/firefox@nightly/windows.json new file mode 100644 index 0000000000..801cb38a95 --- /dev/null +++ b/ee/maintained-apps/outputs/firefox@nightly/windows.json @@ -0,0 +1,22 @@ +{ + "versions": [ + { + "version": "154.2607.1709.0", + "queries": { + "exists": "SELECT 1 FROM programs WHERE name = 'Firefox Nightly' AND publisher = 'Mozilla Corporation';", + "patched": "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM programs WHERE name = 'Firefox Nightly' AND publisher = 'Mozilla Corporation' AND version_compare(version, '154.2607.1709.0') < 0);" + }, + "installer_url": "https://ftp.mozilla.org/pub/firefox/nightly/2026/07/2026-07-17-09-27-13-mozilla-central/firefox-154.0a1.multi.win64.installer.msix", + "install_script_ref": "255e7c51", + "uninstall_script_ref": "f0d0fed1", + "sha256": "b9f11a4edf2929d08c179ea9fc86ccd57a7b7b0b111c3d9742dfb2629a8c6cea", + "default_categories": [ + "Browsers" + ] + } + ], + "refs": { + "255e7c51": "# MSIX: provision machine-wide so the app is available to all users at sign-in, then\n# opportunistically register for the currently logged-on console user (via a scheduled\n# task in their session) so the app is immediately visible without requiring sign-out.\n#\n# The Fleet agent runs as Local System on Windows, and Add-AppxPackage cannot run in that\n# context (HRESULT 0x80073CF9). The scheduled task is the supported way to register a\n# package in a user session from a system-context script.\n\n$softwareName = \"FirefoxNightly\"\n$taskName = \"fleet-install-$softwareName.msix\"\n$scriptPath = \"$env:PUBLIC\\install-$softwareName.ps1\"\n$exitCodeFile = \"$env:PUBLIC\\install-exitcode-$softwareName.txt\"\n\ntry {\n\n $msixPath = $env:INSTALLER_PATH\n if (-not $msixPath) {\n throw \"INSTALLER_PATH is not set\"\n }\n\n Write-Host \"Provisioning MSIX for all users...\"\n $result = Add-AppxProvisionedPackage -Online -PackagePath $msixPath -SkipLicense -Regions \"all\" -ErrorAction Stop\n $result | Out-String | Write-Host\n\n # Win32_ComputerSystem.UserName returns the console user (DOMAIN\\User) or null when no\n # interactive session is active. Other RDP/fast-user-switch sessions won't get the\n # immediate registration; those users will pick it up from the provisioned install at\n # their next sign-in.\n $userName = (Get-CimInstance Win32_ComputerSystem).UserName\n if (-not $userName -or $userName -notlike \"*\\*\") {\n Write-Host \"No interactive user logged on; provisioned install will register for each user at sign-in.\"\n Start-Sleep -Seconds 5\n Exit 0\n }\n\n Write-Host \"Registering MSIX for logged-on user '$userName' via scheduled task...\"\n\n $userScript = @\"\n`$msixPath = \"$msixPath\"\n`$exitCodeFile = \"$exitCodeFile\"\ntry {\n Add-AppxPackage -Path `$msixPath -ErrorAction Stop | Out-String | Write-Host\n Set-Content -Path `$exitCodeFile -Value 0\n} catch {\n Write-Host \"Add-AppxPackage failed: `$(`$_.Exception.Message)\"\n Set-Content -Path `$exitCodeFile -Value 1\n}\n\"@\n\n Set-Content -Path $scriptPath -Value $userScript -Force\n\n $action = New-ScheduledTaskAction -Execute \"powershell.exe\" `\n -Argument \"-WindowStyle Hidden -ExecutionPolicy Bypass -File `\"$scriptPath`\"\"\n $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries\n $principal = New-ScheduledTaskPrincipal -UserId $userName -RunLevel Highest\n $task = New-ScheduledTask -Action $action -Settings $settings -Principal $principal\n Register-ScheduledTask -TaskName $taskName -InputObject $task -User $userName -Force | Out-Null\n Start-ScheduledTask -TaskName $taskName\n\n $startDate = Get-Date\n $state = (Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue).State\n while ($state -ne \"Running\") {\n Start-Sleep -Seconds 1\n if ((New-Timespan -Start $startDate).TotalSeconds -gt 30) {\n Write-Host \"Per-user registration task did not start within 30s; provisioned install is still valid.\"\n break\n }\n $state = (Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue).State\n }\n\n while ($state -eq \"Running\") {\n Start-Sleep -Seconds 2\n if ((New-Timespan -Start $startDate).TotalSeconds -gt 90) {\n Write-Host \"Per-user registration task did not complete within 90s; provisioned install is still valid.\"\n break\n }\n $state = (Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue).State\n }\n\n if (Test-Path $exitCodeFile) {\n $code = (Get-Content $exitCodeFile -ErrorAction SilentlyContinue | Select-Object -First 1).Trim()\n if ($code -eq \"0\") {\n Write-Host \"Per-user registration completed for '$userName'.\"\n } else {\n Write-Host \"Per-user registration did not complete cleanly (exit code: $code). Provisioned install is still valid.\"\n }\n }\n\n Start-Sleep -Seconds 5\n Exit 0\n\n} catch {\n Write-Host \"Error: $_\"\n Exit 1\n} finally {\n Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue | Out-Null\n Remove-Item -Path $scriptPath -Force -ErrorAction SilentlyContinue\n Remove-Item -Path $exitCodeFile -Force -ErrorAction SilentlyContinue\n}\n", + "f0d0fed1": "$timeoutSeconds = 300 # 5 minute timeout\n\n# Match only the Nightly channel: its MSIX identity \"Mozilla.MozillaFirefoxNightly\"\n# cannot collide with other Firefox channels' identities. Don't match on a\n# PackageFamilyName property: Get-AppxProvisionedPackage doesn't expose it, so an\n# \"-eq\" match is $null for every package.\nfunction ShouldRemoveFirefoxNightlyPackage {\n param([Parameter(Mandatory=$true)]$pkg)\n try {\n $name = [string]$pkg.Name\n $family = [string]$pkg.PackageFamilyName\n\n if ($name -and ($name -like \"*MozillaFirefoxNightly*\")) { return $true }\n if ($family -and ($family -like \"*MozillaFirefoxNightly*\")) { return $true }\n } catch {}\n return $false\n}\n\ntry {\n\n $start = Get-Date\n\n $provisioned = Get-AppxProvisionedPackage -Online -ErrorAction Stop | Where-Object {\n ($_.DisplayName -and ($_.DisplayName -like \"*MozillaFirefoxNightly*\")) -or\n ($_.PackageName -and ($_.PackageName -like \"*MozillaFirefoxNightly*\"))\n }\n foreach ($pkg in $provisioned) {\n Write-Host \"Removing provisioned package: $($pkg.PackageName)\"\n Remove-AppxProvisionedPackage -Online -PackageName $pkg.PackageName -AllUsers -ErrorAction Stop | Out-String | Write-Host\n $elapsed = (New-TimeSpan -Start $start).TotalSeconds\n if ($elapsed -gt $timeoutSeconds) {\n Exit 1603\n }\n }\n\n $installed = Get-AppxPackage -AllUsers -PackageTypeFilter Main -ErrorAction SilentlyContinue | Where-Object {\n ShouldRemoveFirefoxNightlyPackage $_\n }\n foreach ($app in $installed) {\n Write-Host \"Removing installed package: $($app.PackageFullName)\"\n Remove-AppxPackage -Package $app.PackageFullName -AllUsers -ErrorAction Stop | Out-String | Write-Host\n $elapsed = (New-TimeSpan -Start $start).TotalSeconds\n if ($elapsed -gt $timeoutSeconds) {\n Exit 1603\n }\n }\n\n Exit 0\n\n} catch {\n Write-Host \"Error: $_\"\n Exit 1603\n}\n" + } +} diff --git a/frontend/pages/SoftwarePage/components/icons/FirefoxDeveloperEdition.tsx b/frontend/pages/SoftwarePage/components/icons/FirefoxDeveloperEdition.tsx new file mode 100644 index 0000000000..eaef007243 --- /dev/null +++ b/frontend/pages/SoftwarePage/components/icons/FirefoxDeveloperEdition.tsx @@ -0,0 +1,14 @@ +import * as React from "react"; + +import type { SVGProps } from "react"; + +const FirefoxDeveloperEdition = (props: SVGProps) => ( + + + +); +export default FirefoxDeveloperEdition; diff --git a/frontend/pages/SoftwarePage/components/icons/FirefoxNightly.tsx b/frontend/pages/SoftwarePage/components/icons/FirefoxNightly.tsx new file mode 100644 index 0000000000..548e7bc22f --- /dev/null +++ b/frontend/pages/SoftwarePage/components/icons/FirefoxNightly.tsx @@ -0,0 +1,14 @@ +import * as React from "react"; + +import type { SVGProps } from "react"; + +const FirefoxNightly = (props: SVGProps) => ( + + + +); +export default FirefoxNightly; diff --git a/frontend/pages/SoftwarePage/components/icons/index.ts b/frontend/pages/SoftwarePage/components/icons/index.ts index b4bbea7c5d..521777be97 100644 --- a/frontend/pages/SoftwarePage/components/icons/index.ts +++ b/frontend/pages/SoftwarePage/components/icons/index.ts @@ -382,6 +382,8 @@ import Firealpaca from "./Firealpaca"; import FireflyIotaDesktop from "./FireflyIotaDesktop"; import FireflyShimmer from "./FireflyShimmer"; import Firefox from "./Firefox"; +import FirefoxDeveloperEdition from "./FirefoxDeveloperEdition"; +import FirefoxNightly from "./FirefoxNightly"; import Fission from "./Fission"; import FleetDesktop from "./FleetDesktop"; import Flexoptix from "./Flexoptix"; @@ -1508,6 +1510,8 @@ export const SOFTWARE_NAME_TO_ICON_MAP = { firefly: FireflyIotaDesktop, "firefly shimmer": FireflyShimmer, firefox: Firefox, + "firefox developer edition": FirefoxDeveloperEdition, + "firefox nightly": FirefoxNightly, fission: Fission, "fleet desktop": FleetDesktop, "flexoptix app": Flexoptix, @@ -1773,6 +1777,8 @@ export const SOFTWARE_NAME_TO_ICON_MAP = { mos: Mos, "mountain duck": MountainDuck, "mozilla firefox": Firefox, + "mozilla firefox developer edition": FirefoxDeveloperEdition, + "mozilla firefox nightly": FirefoxNightly, "mozilla vpn": MozillaVpn, mqttx: Mqttx, "mullvad browser": MullvadBrowser, diff --git a/website/assets/images/app-icon-firefox@developer-edition-60x60@2x.png b/website/assets/images/app-icon-firefox@developer-edition-60x60@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..6d680ce02f44a83acbe2a3fd335cac9c5b023e93 GIT binary patch literal 15646 zcma*OLtrIL6E=Ec+qP}n*2K0awtZsTwrx+GOl)gnTlf9$;@;)|FRH7$x_VKY?yjfn ziBwjULV(4E1pojDGScFz|LMU02{hz?b(Eo2WumJ4;xBnL?9S80K0Pq84#6^C2 zf?VaoWavw|ozD6B+?;FJwzjS%#n|^u{J~ozmL3Ri@DF5T13?=Oofg(=LP>%*OJZW9 z-<7{lL{6HP7saO}@g*1L#D|B0gx|5(7T+<`j&5yTVtCrQ>Ghxb{4T%t`?}OR_pklE zKH)0!RP}qg@+wEITrs!GXVY$i!Pp1}Ob%OXXaE&5jkzH-hytGe|Kz%LzyX_oVCjgR zYGY`YzhkYINY_TUZ|Qo{`b8C!mbrX4qiFqH({l2O<=xZwk|_`qj&sp~Q@Aczoora` zz(r@b%jX-hFk8WA3g6Io+O_km>sbV{A3@ZCuekVgbrCzpD8RmTD}MmqXHL8wD;I?G z*^n&wX9IO!m)!DK|1kYSX0O=phfXwwE~p_pMa`!vV?~g zhtHIWOz0zFG}9F`J(fP z^w!$w@bbJq(?UEwWyrt0yPIAsL`w|s6udfG|7HbupX~i+rmjx=AG_1M=e<_+$JYe{ z-k#XjOYNsNR_(85?O*>$TL(B5;wm!ylDJrvo2U-RZvEDWQ$K~P%;WmJ?7l(>xP_tf za9hu_ZjPgC-$=7%6l&J5+00fhAu_{i{cooMG6AvGEAsAnI^(Y~1L`N~qyDc$qNZ=I z{@KtP)or^93(EP<{War{nTwrgUspGW+N>_$pYdEO#r8XhL_nqdRsXjxxj){>PbQQ9 z$|XX5b2+UA5BDDS2$hWlwz1h0Zuh>}4UazVbou9>UuEy=uzAnmg7ySPrn?+MazcWF z`6@ksr#`1Oztah}dPXHl-eH+J5a-Kp`E@XahUUVs>>H(izI8S53514+BRLsu`E1QR z&j+^47jLau2D(z2wo|Gk0s?pmJPXtO4#xz|(CR%qV@2%Ke09@V_}~YtS-^lWHq-s{ zcC&2;zIg(VnwGyvV&*Zo|86%P+(=R)5#=!vp7w$-T|7~%*Zyqy;`x)Lckd71v~4ME znUN`Xic7H}>x9;iKWyFd%h9f3=w3ZBPe1IdwthOyU-z7G-vr~m*MB8e5cWR2VLzI* zbv_TxwB7mLI7!sYi)DT38=WCH8^Q}CV~b29e+W2^yxnw7%L&3v;A_!=lk7Fpq#aPQ~WG zW9Yg+pLcS$8#z{XT^KzS9rwWIE(a`ISo~aI*?}`9L>asfzN{Iue^?x(mw;K7Esp&` z{rf4eCtsjHc#UCuITrAfJU47rbg}xP_g3}B&QAmhTu}FU@Wu5ZNuQs-@*7j&gy(3z zj{bKY$+nMoU?5-Mx(Z{`VMfD^*bM)B-K74F$cTa(Vel1MI z{ql9w7QOqU-GySp1O7^R_Wn+d{uPmtcaF%C!YZD;WVLro=WJA)-+8@lz&xjH^i z{}PS%T3*9lNz%w+opX);@0{o2b1yjOKC<_k11$KX^yR#2h32 zXS{Eq?^w@7lPubOMSYPn&(7-?)WxUKu&HSbxmX5~d{)=08<~z0oue

_elZv#M9hu?i&N6!ZU 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 Hje`FljqOQ1 literal 0 HcmV?d00001 diff --git a/website/assets/images/app-icon-firefox@nightly-60x60@2x.png b/website/assets/images/app-icon-firefox@nightly-60x60@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..122b0bcdef467b859906e8eb85d72c7a95aebb08 GIT binary patch literal 14681 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