diff --git a/packages/browseros/tools/bros/cmd/build.go b/packages/browseros/tools/bros/cmd/build.go new file mode 100644 index 00000000..41a6d10a --- /dev/null +++ b/packages/browseros/tools/bros/cmd/build.go @@ -0,0 +1,102 @@ +package cmd + +import ( + nativebuild "bros/internal/native/build" + + "github.com/spf13/cobra" +) + +type buildOptions struct { + Config string + Modules string + List bool + Setup bool + Prep bool + Build bool + Sign bool + Package bool + Upload bool + Arch string + BuildType string + ChromiumSrc string +} + +var ( + buildRootOpts buildOptions + buildRunOpts buildOptions +) + +var buildCmd = &cobra.Command{ + Use: "build", + Short: "Build BrowserOS browser", + Long: "Build and patch-development commands for BrowserOS.", + RunE: func(cmd *cobra.Command, args []string) error { + return runBuildCommand(buildRootOpts) + }, +} + +var buildRunCmd = &cobra.Command{ + Use: "run", + Short: "Run build pipeline", + RunE: func(cmd *cobra.Command, args []string) error { + return runBuildCommand(buildRunOpts) + }, +} + +var buildModulesCmd = &cobra.Command{ + Use: "modules", + Short: "Module inspection commands", + Run: func(cmd *cobra.Command, args []string) { + _ = cmd.Help() + }, +} + +var buildModulesListCmd = &cobra.Command{ + Use: "list", + Short: "List available build modules", + RunE: func(cmd *cobra.Command, args []string) error { + nativebuild.PrintModuleList() + return nil + }, +} + +func init() { + addBuildFlags(buildCmd, &buildRootOpts) + addBuildFlags(buildRunCmd, &buildRunOpts) + + buildModulesCmd.AddCommand(buildModulesListCmd) + buildCmd.AddCommand(buildRunCmd, buildModulesCmd, newDevSurfaceCommand("patch", "Patch management commands")) + rootCmd.AddCommand(buildCmd) +} + +func addBuildFlags(cmd *cobra.Command, opts *buildOptions) { + cmd.Flags().StringVarP(&opts.Config, "config", "c", "", "load configuration from YAML file") + cmd.Flags().StringVarP(&opts.Modules, "modules", "m", "", "comma-separated list of modules to run") + cmd.Flags().BoolVarP(&opts.List, "list", "l", false, "list all available modules and exit") + cmd.Flags().BoolVar(&opts.Setup, "setup", false, "run setup phase") + cmd.Flags().BoolVar(&opts.Prep, "prep", false, "run prep phase") + cmd.Flags().BoolVar(&opts.Build, "build", false, "run build phase") + cmd.Flags().BoolVar(&opts.Sign, "sign", false, "run sign phase") + cmd.Flags().BoolVar(&opts.Package, "package", false, "run package phase") + cmd.Flags().BoolVar(&opts.Upload, "upload", false, "run upload phase") + cmd.Flags().StringVarP(&opts.Arch, "arch", "a", "", "target architecture") + cmd.Flags().StringVarP(&opts.BuildType, "build-type", "t", "", "build type (debug|release)") + cmd.Flags().StringVarP(&opts.ChromiumSrc, "chromium-src", "S", "", "path to Chromium source directory") +} + +func runBuildCommand(opts buildOptions) error { + return nativebuild.Run(nativebuild.Options{ + ConfigPath: opts.Config, + Modules: opts.Modules, + ListModules: opts.List, + Setup: opts.Setup, + Prep: opts.Prep, + Build: opts.Build, + Sign: opts.Sign, + Package: opts.Package, + Upload: opts.Upload, + Arch: opts.Arch, + BuildType: opts.BuildType, + ChromiumSrc: opts.ChromiumSrc, + }) +} diff --git a/packages/browseros/tools/bros/cmd/dev.go b/packages/browseros/tools/bros/cmd/dev.go new file mode 100644 index 00000000..65d239d6 --- /dev/null +++ b/packages/browseros/tools/bros/cmd/dev.go @@ -0,0 +1,129 @@ +package cmd + +import ( + nativedev "bros/internal/native/dev" + + "github.com/spf13/cobra" +) + +type devOptions struct { + ChromiumSrc string + Verbose bool + Quiet bool +} + +func init() { + rootCmd.AddCommand(newDevSurfaceCommand("dev", "Patch development commands")) +} + +func newDevSurfaceCommand(use string, short string) *cobra.Command { + opts := &devOptions{} + + devCmd := &cobra.Command{ + Use: use, + Short: short, + } + + devCmd.PersistentFlags().StringVarP(&opts.ChromiumSrc, "chromium-src", "S", "", "path to Chromium source directory") + devCmd.PersistentFlags().BoolVarP(&opts.Verbose, "verbose", "v", false, "enable verbose output") + devCmd.PersistentFlags().BoolVarP(&opts.Quiet, "quiet", "q", false, "suppress non-essential output") + + statusCmd := &cobra.Command{ + Use: "status", + Short: "Show patch dev status", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return runDevCommand(opts, []string{"status"}, nil) + }, + } + + annotateCmd := &cobra.Command{ + Use: "annotate [feature_name]", + Short: "Create git commits organized by features", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runDevCommand(opts, []string{"annotate"}, args) + }, + } + + featureCmd := &cobra.Command{ + Use: "feature", + Short: "Feature management commands", + Run: func(cmd *cobra.Command, args []string) { + _ = cmd.Help() + }, + } + + featureListCmd := &cobra.Command{ + Use: "list", + Short: "List all defined features", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return runDevCommand(opts, []string{"feature", "list"}, nil) + }, + } + + featureShowCmd := &cobra.Command{ + Use: "show ", + Short: "Show details for a feature", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runDevCommand(opts, []string{"feature", "show"}, args) + }, + } + + featureClassifyCmd := &cobra.Command{ + Use: "classify", + Short: "Classify unassigned patch files into features", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return runDevCommand(opts, []string{"feature", "classify"}, nil) + }, + } + + var featureName string + var featureCommit string + var featureDescription string + featureAddUpdateCmd := &cobra.Command{ + Use: "add-update", + Short: "Add or update a feature using commit files", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + devArgs := []string{ + "--name", featureName, + "--commit", featureCommit, + "--description", featureDescription, + } + return runDevCommand(opts, []string{"feature", "add-update"}, devArgs) + }, + } + featureAddUpdateCmd.Flags().StringVarP(&featureName, "name", "n", "", "feature name (lowercase kebab-case)") + featureAddUpdateCmd.Flags().StringVarP(&featureCommit, "commit", "c", "", "git commit reference") + featureAddUpdateCmd.Flags().StringVarP(&featureDescription, "description", "d", "", "feature description with prefix (feat:, fix:, build:, chore:, series:)") + _ = featureAddUpdateCmd.MarkFlagRequired("name") + _ = featureAddUpdateCmd.MarkFlagRequired("commit") + _ = featureAddUpdateCmd.MarkFlagRequired("description") + + featureCmd.AddCommand(featureListCmd, featureShowCmd, featureAddUpdateCmd, featureClassifyCmd) + devCmd.AddCommand(statusCmd, annotateCmd, featureCmd) + + return devCmd +} + +func runDevCommand(opts *devOptions, trail []string, passthrough []string) error { + base := []string{} + + if opts.ChromiumSrc != "" { + base = append(base, "--chromium-src", opts.ChromiumSrc) + } + if opts.Verbose { + base = append(base, "--verbose") + } + if opts.Quiet { + base = append(base, "--quiet") + } + + base = append(base, trail...) + base = append(base, passthrough...) + return nativedev.Run(base) +} diff --git a/packages/browseros/tools/bros/cmd/release.go b/packages/browseros/tools/bros/cmd/release.go new file mode 100644 index 00000000..f54c3f24 --- /dev/null +++ b/packages/browseros/tools/bros/cmd/release.go @@ -0,0 +1,459 @@ +package cmd + +import ( + "context" + "fmt" + "strings" + + nativecommon "bros/internal/native/common" + nativeota "bros/internal/native/ota" + nativer2 "bros/internal/native/r2" + nativerelease "bros/internal/native/release" + + "github.com/spf13/cobra" +) + +type releaseCompatOptions struct { + Version string + List bool + Appcast bool + Publish bool + Download bool + OSFilter string + Output string + ShowModules bool +} + +var releaseCompatOpts releaseCompatOptions + +var releaseCmd = &cobra.Command{ + Use: "release", + Short: "Release automation commands", + RunE: func(cmd *cobra.Command, args []string) error { + return runReleaseCompat(releaseCompatOpts) + }, +} + +func init() { + releaseCmd.Flags().StringVarP(&releaseCompatOpts.Version, "version", "v", "", "version to operate on") + releaseCmd.Flags().BoolVarP(&releaseCompatOpts.List, "list", "l", false, "list available artifacts") + releaseCmd.Flags().BoolVarP(&releaseCompatOpts.Appcast, "appcast", "a", false, "generate appcast XML") + releaseCmd.Flags().BoolVarP(&releaseCompatOpts.Publish, "publish", "p", false, "publish to download paths") + releaseCmd.Flags().BoolVarP(&releaseCompatOpts.Download, "download", "d", false, "download artifacts") + releaseCmd.Flags().StringVar(&releaseCompatOpts.OSFilter, "os", "", "filter by OS (macos, windows, linux)") + releaseCmd.Flags().StringVarP(&releaseCompatOpts.Output, "output", "o", "", "download output directory") + releaseCmd.Flags().BoolVar(&releaseCompatOpts.ShowModules, "show-modules", false, "show available modules") + + releaseCmd.AddCommand( + newReleaseListCommand(), + newReleaseAppcastCommand(), + newReleasePublishCommand(), + newReleaseDownloadCommand(), + newReleaseGithubCommand(), + newOTACommand("ota", "OTA update automation"), + ) + + rootCmd.AddCommand(releaseCmd) + rootCmd.AddCommand(newOTACommand("ota", "OTA update automation (alias of bros release ota)")) +} + +func runReleaseCompat(opts releaseCompatOptions) error { + client, err := newReleaseClient() + if err != nil { + return err + } + + result, err := nativerelease.Run(context.Background(), client, nativerelease.Options{ + Version: opts.Version, + List: opts.List, + Appcast: opts.Appcast, + Publish: opts.Publish, + Download: opts.Download, + OSFilter: opts.OSFilter, + Output: opts.Output, + ShowModules: opts.ShowModules, + }) + if err != nil { + return err + } + + renderReleaseResult(opts, result) + return nil +} + +func newReleaseClient() (*nativer2.Client, error) { + packagesDir, err := nativecommon.ResolveBrowserOSPackagesDir() + if err != nil { + return nil, err + } + _ = nativecommon.LoadEnv(packagesDir) + return nativer2.NewClientFromEnv() +} + +func renderReleaseResult(opts releaseCompatOptions, result nativerelease.RunResult) { + if len(result.Modules) > 0 { + fmt.Println("Available release modules:") + for _, m := range result.Modules { + fmt.Printf(" %s: %s\n", m.Name, m.Description) + } + return + } + + if opts.List { + if opts.Version == "" { + if len(result.Versions) == 0 { + fmt.Println("No releases found in R2") + return + } + fmt.Printf("Available releases (%d total):\n", len(result.Versions)) + for _, version := range result.Versions { + fmt.Printf(" %s\n", version) + } + } else { + renderVersionMetadata(opts.Version, result.Metadata) + } + } + + if opts.Appcast && len(result.Appcast) > 0 { + fmt.Println() + fmt.Printf("APPCAST SNIPPETS FOR v%s\n", opts.Version) + for _, snippet := range result.Appcast { + fmt.Println() + fmt.Printf("%s (%s):\n", snippet.Filename, snippet.Arch) + fmt.Println(snippet.ItemXML) + } + } + + if opts.Publish { + fmt.Println() + success := 0 + for _, r := range result.PublishResults { + if r.Err == nil { + success++ + fmt.Printf("✓ %s -> %s\n", r.Filename, r.DestinationKey) + } else { + fmt.Printf("✗ %s -> %s (%v)\n", r.Filename, r.DestinationKey, r.Err) + } + } + fmt.Printf("Published %d/%d artifacts\n", success, len(result.PublishResults)) + } + + if opts.Download { + fmt.Println() + for _, r := range result.Download.Results { + if r.Err == nil { + fmt.Printf("✓ %s (%s)\n", r.Filename, nativerelease.FormatSize(r.BytesWritten)) + } else { + fmt.Printf("✗ %s (%v)\n", r.Filename, r.Err) + } + } + if strings.TrimSpace(result.Download.Directory) != "" { + fmt.Printf("Downloaded to: %s\n", result.Download.Directory) + } + } +} + +func renderVersionMetadata(version string, metadata map[string]nativerelease.PlatformMetadata) { + if len(metadata) == 0 { + fmt.Printf("No release metadata found for version %s\n", version) + return + } + + fmt.Printf("Release: v%s\n", version) + for _, platform := range nativerelease.Platforms { + release, ok := metadata[platform] + if !ok { + continue + } + fmt.Printf("\n%s:\n", nativerelease.PlatformDisplayNames[platform]) + fmt.Printf(" Build Date: %s\n", release.BuildDate) + fmt.Printf(" Chromium: %s\n", release.ChromiumVersion) + if platform == nativerelease.PlatformMacOS && release.SparkleVersion != "" { + fmt.Printf(" Sparkle Version: %s\n", release.SparkleVersion) + } + keys := make([]string, 0, len(release.Artifacts)) + for key := range release.Artifacts { + keys = append(keys, key) + } + // deterministic display + for _, key := range keys { + artifact := release.Artifacts[key] + size := nativerelease.FormatSize(artifact.Size) + sig := "" + if artifact.SparkleSignature != "" { + sig = " [signed]" + } + fmt.Printf(" - %s: %s (%s)%s\n", key, artifact.Filename, size, sig) + if artifact.URL != "" { + fmt.Printf(" %s\n", artifact.URL) + } + } + } +} + +func newReleaseListCommand() *cobra.Command { + var version string + + cmd := &cobra.Command{ + Use: "list", + Short: "List release versions or artifacts", + RunE: func(cmd *cobra.Command, args []string) error { + return runReleaseCompat(releaseCompatOptions{ + Version: version, + List: true, + }) + }, + } + + cmd.Flags().StringVarP(&version, "version", "v", "", "version to list artifacts for") + return cmd +} + +func newReleaseAppcastCommand() *cobra.Command { + var version string + + cmd := &cobra.Command{ + Use: "appcast", + Short: "Generate appcast XML snippets", + RunE: func(cmd *cobra.Command, args []string) error { + return runReleaseCompat(releaseCompatOptions{ + Version: version, + Appcast: true, + }) + }, + } + + cmd.Flags().StringVarP(&version, "version", "v", "", "version to generate appcast for") + _ = cmd.MarkFlagRequired("version") + return cmd +} + +func newReleasePublishCommand() *cobra.Command { + var version string + + cmd := &cobra.Command{ + Use: "publish", + Short: "Publish release artifacts to download paths", + RunE: func(cmd *cobra.Command, args []string) error { + return runReleaseCompat(releaseCompatOptions{ + Version: version, + Publish: true, + }) + }, + } + + cmd.Flags().StringVarP(&version, "version", "v", "", "version to publish") + _ = cmd.MarkFlagRequired("version") + return cmd +} + +func newReleaseDownloadCommand() *cobra.Command { + var version string + var osFilter string + var output string + + cmd := &cobra.Command{ + Use: "download", + Short: "Download release artifacts", + RunE: func(cmd *cobra.Command, args []string) error { + return runReleaseCompat(releaseCompatOptions{ + Version: version, + Download: true, + OSFilter: osFilter, + Output: output, + }) + }, + } + + cmd.Flags().StringVarP(&version, "version", "v", "", "version to download") + cmd.Flags().StringVar(&osFilter, "os", "", "filter by OS (macos, windows, linux)") + cmd.Flags().StringVarP(&output, "output", "o", "", "download output directory") + _ = cmd.MarkFlagRequired("version") + return cmd +} + +func newReleaseGithubCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "github", + Short: "GitHub release operations", + Run: func(cmd *cobra.Command, args []string) { + _ = cmd.Help() + }, + } + + var ( + version string + repo string + title string + skipUpload bool + noDraft bool + publishToDownload bool + ) + + createCmd := &cobra.Command{ + Use: "create", + Short: "Create GitHub release from R2 artifacts", + RunE: func(cmd *cobra.Command, args []string) error { + client, err := newReleaseClient() + if err != nil { + return err + } + result, err := nativerelease.CreateAndUploadGitHubRelease(context.Background(), client, nativerelease.GitHubReleaseOptions{ + Version: version, + Repo: repo, + Title: title, + Draft: !noDraft, + SkipUpload: skipUpload, + }) + if err != nil { + return err + } + + if result.ReleaseExisted { + fmt.Printf("Release v%s already exists\n", result.TagVersion) + } else if strings.TrimSpace(result.ReleaseURL) != "" { + fmt.Printf("Release created: %s\n", strings.TrimSpace(result.ReleaseURL)) + } else { + fmt.Printf("Release v%s created\n", result.TagVersion) + } + + if len(result.UploadResults) > 0 { + for _, upload := range result.UploadResults { + if upload.Err == nil { + fmt.Printf("✓ Uploaded %s\n", upload.Filename) + } else { + fmt.Printf("✗ Failed %s: %v\n", upload.Filename, upload.Err) + } + } + } + + if len(result.Appcast) > 0 { + fmt.Println() + fmt.Println("APPCAST SNIPPETS:") + for _, snippet := range result.Appcast { + fmt.Printf("\n%s (%s):\n%s\n", snippet.Filename, snippet.Arch, snippet.ItemXML) + } + } + + if publishToDownload { + return runReleaseCompat(releaseCompatOptions{ + Version: version, + Publish: true, + }) + } + return nil + }, + } + + createCmd.Flags().StringVarP(&version, "version", "v", "", "version to release (e.g., 0.31.0)") + createCmd.Flags().BoolVar(&noDraft, "no-draft", false, "create published release instead of draft") + createCmd.Flags().StringVarP(&repo, "repo", "r", "", "GitHub repo (owner/name)") + createCmd.Flags().BoolVar(&skipUpload, "skip-upload", false, "skip uploading artifacts to GitHub") + createCmd.Flags().StringVarP(&title, "title", "t", "", "release title (default: v{version})") + createCmd.Flags().BoolVarP(&publishToDownload, "publish", "p", false, "also publish to download/ paths after creating release") + _ = createCmd.MarkFlagRequired("version") + + cmd.AddCommand(createCmd) + return cmd +} + +func newOTACommand(use string, short string) *cobra.Command { + otaCmd := &cobra.Command{ + Use: use, + Short: short, + } + + testSigningCmd := &cobra.Command{ + Use: "test-signing ", + Short: "Test Sparkle Ed25519 signing on a file", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runOTACommand([]string{"ota", "test-signing", args[0]}) + }, + } + + serverCmd := &cobra.Command{ + Use: "server", + Short: "BrowserOS Server OTA commands", + Run: func(cmd *cobra.Command, args []string) { + _ = cmd.Help() + }, + } + + var ( + releaseVersion string + releaseChannel string + releaseBinaries string + releasePlatform string + ) + serverReleaseCmd := &cobra.Command{ + Use: "release", + Short: "Create and upload server OTA artifacts", + RunE: func(cmd *cobra.Command, args []string) error { + argv := []string{"ota", "server", "release", "--version", releaseVersion} + if strings.TrimSpace(releaseChannel) != "" { + argv = append(argv, "--channel", releaseChannel) + } + if strings.TrimSpace(releaseBinaries) != "" { + argv = append(argv, "--binaries", releaseBinaries) + } + if strings.TrimSpace(releasePlatform) != "" { + argv = append(argv, "--platform", releasePlatform) + } + return runOTACommand(argv) + }, + } + serverReleaseCmd.Flags().StringVarP(&releaseVersion, "version", "v", "", "version to release") + serverReleaseCmd.Flags().StringVarP(&releaseChannel, "channel", "c", "alpha", "release channel: alpha or prod") + serverReleaseCmd.Flags().StringVarP(&releaseBinaries, "binaries", "b", "", "directory containing server binaries") + serverReleaseCmd.Flags().StringVarP(&releasePlatform, "platform", "p", "", "platform(s) to process, comma-separated") + _ = serverReleaseCmd.MarkFlagRequired("version") + + var ( + publishChannel string + publishFile string + ) + serverPublishAppcastCmd := &cobra.Command{ + Use: "publish-appcast", + Aliases: []string{"release-appcast"}, + Short: "Publish OTA appcast to make release live", + RunE: func(cmd *cobra.Command, args []string) error { + argv := []string{"ota", "server", "publish-appcast"} + if strings.TrimSpace(publishChannel) != "" { + argv = append(argv, "--channel", publishChannel) + } + if strings.TrimSpace(publishFile) != "" { + argv = append(argv, "--file", publishFile) + } + return runOTACommand(argv) + }, + } + serverPublishAppcastCmd.Flags().StringVarP(&publishChannel, "channel", "c", "alpha", "release channel: alpha or prod") + serverPublishAppcastCmd.Flags().StringVarP(&publishFile, "file", "f", "", "custom appcast file to upload") + + serverListPlatformsCmd := &cobra.Command{ + Use: "list-platforms", + Short: "List supported OTA server platforms", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return runOTACommand([]string{"ota", "server", "list-platforms"}) + }, + } + + serverCmd.AddCommand(serverReleaseCmd, serverPublishAppcastCmd, serverListPlatformsCmd) + otaCmd.AddCommand(testSigningCmd, serverCmd) + + return otaCmd +} + +func runOTACommand(args []string) error { + packagesDir, err := nativecommon.ResolveBrowserOSPackagesDir() + if err != nil { + return err + } + handled, err := nativeota.Run(args, packagesDir) + if !handled { + return fmt.Errorf("unsupported ota command") + } + return err +} diff --git a/packages/browseros/tools/bros/cmd/root.go b/packages/browseros/tools/bros/cmd/root.go index 6af44ad1..f353d300 100644 --- a/packages/browseros/tools/bros/cmd/root.go +++ b/packages/browseros/tools/bros/cmd/root.go @@ -5,22 +5,17 @@ import ( ) var ( - verbose bool version string ) var rootCmd = &cobra.Command{ - Use: "bros", - Short: "BrowserOS CLI — patch management, builds, and releases", - Long: "bros manages BrowserOS patches across Chromium checkouts.\nUse push/pull to sync patches, clone for fresh applies.", + Use: "bros", + Short: "BrowserOS CLI — patch management, builds, and releases", + Long: "bros manages BrowserOS patches across Chromium checkouts.\nUse push/pull to sync patches, clone for fresh applies.", SilenceUsage: true, SilenceErrors: true, } -func init() { - rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "increase output detail") -} - func SetVersion(v string) { version = v rootCmd.Version = v diff --git a/packages/browseros/tools/bros/go.mod b/packages/browseros/tools/bros/go.mod index 298de45e..11dd6636 100644 --- a/packages/browseros/tools/bros/go.mod +++ b/packages/browseros/tools/bros/go.mod @@ -10,6 +10,25 @@ require ( ) require ( + github.com/aws/aws-sdk-go-v2 v1.41.1 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect + github.com/aws/aws-sdk-go-v2/config v1.32.7 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.19.7 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17 // indirect + github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // indirect + github.com/aws/smithy-go v1.24.0 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect github.com/charmbracelet/x/ansi v0.8.0 // indirect diff --git a/packages/browseros/tools/bros/go.sum b/packages/browseros/tools/bros/go.sum index 6f9e4c1d..c80dca6f 100644 --- a/packages/browseros/tools/bros/go.sum +++ b/packages/browseros/tools/bros/go.sum @@ -1,3 +1,41 @@ +github.com/aws/aws-sdk-go-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6cerhU= +github.com/aws/aws-sdk-go-v2 v1.41.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4= +github.com/aws/aws-sdk-go-v2/config v1.32.7 h1:vxUyWGUwmkQ2g19n7JY/9YL8MfAIl7bTesIUykECXmY= +github.com/aws/aws-sdk-go-v2/config v1.32.7/go.mod h1:2/Qm5vKUU/r7Y+zUk/Ptt2MDAEKAfUtKc1+3U1Mo3oY= +github.com/aws/aws-sdk-go-v2/credentials v1.19.7 h1:tHK47VqqtJxOymRrNtUXN5SP/zUTvZKeLx4tH6PGQc8= +github.com/aws/aws-sdk-go-v2/credentials v1.19.7/go.mod h1:qOZk8sPDrxhf+4Wf4oT2urYJrYt3RejHSzgAquYeppw= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 h1:I0GyV8wiYrP8XpA70g1HBcQO1JlQxCMTW9npl5UbDHY= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17/go.mod h1:tyw7BOl5bBe/oqvoIeECFJjMdzXoa/dfVz3QQ5lgHGA= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 h1:xOLELNKGp2vsiteLsvLPwxC+mYmO6OZ8PYgiuPJzF8U= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17/go.mod h1:5M5CI3D12dNOtH3/mk6minaRwI2/37ifCURZISxA/IQ= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 h1:WWLqlh79iO48yLkj1v3ISRNiv+3KdQoZ6JWyfcsyQik= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17/go.mod h1:EhG22vHRrvF8oXSTYStZhJc1aUgKtnJe+aOiFEV90cM= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17 h1:JqcdRG//czea7Ppjb+g/n4o8i/R50aTBHkA7vu0lK+k= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17/go.mod h1:CO+WeGmIdj/MlPel2KwID9Gt7CNq4M65HUfBW97liM0= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8 h1:Z5EiPIzXKewUQK0QTMkutjiaPVeVYXX7KIqhXu/0fXs= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8/go.mod h1:FsTpJtvC4U1fyDXk7c71XoDv3HlRm8V3NiYLeYLh5YE= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 h1:RuNSMoozM8oXlgLG/n6WLaFGoea7/CddrCfIiSA+xdY= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17/go.mod h1:F2xxQ9TZz5gDWsclCtPQscGpP0VUOc8RqgFM3vDENmU= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17 h1:bGeHBsGZx0Dvu/eJC0Lh9adJa3M1xREcndxLNZlve2U= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17/go.mod h1:dcW24lbU0CzHusTE8LLHhRLI42ejmINN8Lcr22bwh/g= +github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0 h1:oeu8VPlOre74lBA/PMhxa5vewaMIMmILM+RraSyB8KA= +github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0/go.mod h1:5jggDlZ2CLQhwJBiZJb4vfk4f0GxWdEDruWKEJ1xOdo= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 h1:VrhDvQib/i0lxvr3zqlUwLwJP4fpmpyD9wYG1vfSu+Y= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.5/go.mod h1:k029+U8SY30/3/ras4G/Fnv/b88N4mAfliNn08Dem4M= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 h1:v6EiMvhEYBoHABfbGB4alOYmCIrcgyPPiBE1wZAEbqk= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.9/go.mod h1:yifAsgBxgJWn3ggx70A3urX2AN49Y5sJTD1UQFlfqBw= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13 h1:gd84Omyu9JLriJVCbGApcLzVR3XtmC4ZDPcAI6Ftvds= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13/go.mod h1:sTGThjphYE4Ohw8vJiRStAcu3rbjtXRsdNB0TvZ5wwo= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 h1:5fFjR/ToSOzB2OQ/XqWpZBmNvmP/pJ1jOWYlFDJTjRQ= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.6/go.mod h1:qgFDZQSD/Kys7nJnVqYlWKnh0SSdMjAi0uSwON4wgYQ= +github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk= +github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= diff --git a/packages/browseros/tools/bros/internal/exitcode/error.go b/packages/browseros/tools/bros/internal/exitcode/error.go new file mode 100644 index 00000000..7d1be713 --- /dev/null +++ b/packages/browseros/tools/bros/internal/exitcode/error.go @@ -0,0 +1,31 @@ +package exitcode + +import "fmt" + +// Error carries an explicit process exit code. +type Error struct { + code int + msg string +} + +func New(code int, msg string) *Error { + if code <= 0 { + code = 1 + } + return &Error{code: code, msg: msg} +} + +func (e *Error) Error() string { + if e.msg != "" { + return e.msg + } + return fmt.Sprintf("exit status %d", e.code) +} + +func (e *Error) ExitCode() int { + return e.code +} + +func (e *Error) HasMessage() bool { + return e.msg != "" +} diff --git a/packages/browseros/tools/bros/internal/native/build/context.go b/packages/browseros/tools/bros/internal/native/build/context.go new file mode 100644 index 00000000..56728c5e --- /dev/null +++ b/packages/browseros/tools/bros/internal/native/build/context.go @@ -0,0 +1,316 @@ +package build + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + "strconv" + "strings" + + nativecommon "bros/internal/native/common" +) + +const appBaseName = "BrowserOS" + +type SparkleSignature struct { + Signature string + Length int64 +} + +type Context struct { + PackagesDir string + ChromiumSrc string + Architecture string + BuildType string + OutDir string + ChromiumVersion string + BrowserOSBuildOffset string + BrowserOSChromiumVersion string + SemanticVersion string + SparkleVersion string + FixedAppPath string + SignedApp bool + SparkleSignatures map[string]SparkleSignature + Env nativecommon.EnvConfig +} + +func (c *Context) ConfigDir() string { + return filepath.Join(c.PackagesDir, "build", "config") +} + +func (c *Context) PatchesDir() string { + return filepath.Join(c.PackagesDir, "chromium_patches") +} + +func (c *Context) SeriesPatchesDir() string { + return filepath.Join(c.PackagesDir, "series_patches") +} + +func (c *Context) ChromiumFilesDir() string { + return filepath.Join(c.PackagesDir, "chromium_files") +} + +func (c *Context) CopyResourcesConfig() string { + return filepath.Join(c.ConfigDir(), "copy_resources.yaml") +} + +func (c *Context) DownloadResourcesConfig() string { + return filepath.Join(c.ConfigDir(), "download_resources.yaml") +} + +func (c *Context) GNFlagsPath() string { + return filepath.Join(c.ConfigDir(), "gn", fmt.Sprintf("flags.%s.%s.gn", currentPlatformName(), c.BuildType)) +} + +func (c *Context) GNArgsPath() string { + return filepath.Join(c.ChromiumSrc, c.OutDir, "args.gn") +} + +func (c *Context) EntitlementsDir() string { + return filepath.Join(c.PackagesDir, "resources", "entitlements") +} + +func (c *Context) PkgDMGPath() string { + return filepath.Join(c.ChromiumSrc, "chrome", "installer", "mac", "pkg-dmg") +} + +func (c *Context) NotarizationZipPath() string { + return filepath.Join(c.ChromiumSrc, c.OutDir, "notarize.zip") +} + +func (c *Context) ChromiumAppPath() string { + switch runtime.GOOS { + case "darwin": + return filepath.Join(c.ChromiumSrc, c.OutDir, "Chromium.app") + case "windows": + return filepath.Join(c.ChromiumSrc, c.OutDir, "chrome.exe") + default: + return filepath.Join(c.ChromiumSrc, c.OutDir, "chrome") + } +} + +func (c *Context) BrowserOSAppPath() string { + if strings.TrimSpace(c.FixedAppPath) != "" { + return c.FixedAppPath + } + + if runtime.GOOS == "darwin" { + universalPath := filepath.Join(c.ChromiumSrc, "out", "Default_universal", "BrowserOS.app") + if fileExists(universalPath) { + return universalPath + } + if c.BuildType == "debug" { + debugPath := filepath.Join(c.ChromiumSrc, c.OutDir, "BrowserOS Dev.app") + if fileExists(debugPath) { + return debugPath + } + } + return filepath.Join(c.ChromiumSrc, c.OutDir, "BrowserOS.app") + } + if runtime.GOOS == "windows" { + return filepath.Join(c.ChromiumSrc, c.OutDir, "BrowserOS.exe") + } + return filepath.Join(c.ChromiumSrc, c.OutDir, strings.ToLower(appBaseName)) +} + +func (c *Context) SparkleDir() string { + return filepath.Join(c.ChromiumSrc, "third_party", "sparkle") +} + +func (c *Context) SparkleURL() string { + return fmt.Sprintf("https://github.com/sparkle-project/Sparkle/releases/download/%s/Sparkle-%s.tar.xz", c.SparkleVersion, c.SparkleVersion) +} + +func (c *Context) DistDir() string { + return filepath.Join(c.PackagesDir, "releases", c.SemanticVersion) +} + +func (c *Context) ReleasePath(platform string) string { + return fmt.Sprintf("releases/%s/%s/", c.SemanticVersion, platform) +} + +func (c *Context) ArtifactName(artifactType string) (string, error) { + if strings.TrimSpace(c.SemanticVersion) == "" { + return "", fmt.Errorf("semantic version is not set") + } + + version := c.SemanticVersion + arch := strings.TrimSpace(c.Architecture) + if arch == "" { + arch = defaultArch() + } + + switch artifactType { + case "dmg": + return fmt.Sprintf("%s_v%s_%s.dmg", appBaseName, version, arch), nil + case "appimage": + return fmt.Sprintf("%s_v%s_%s.AppImage", appBaseName, version, arch), nil + case "deb": + debArch := arch + if arch == "x64" { + debArch = "amd64" + } + return fmt.Sprintf("%s_v%s_%s.deb", appBaseName, version, debArch), nil + case "installer": + return fmt.Sprintf("%s_v%s_%s_installer.exe", appBaseName, version, arch), nil + case "installer_zip": + return fmt.Sprintf("%s_v%s_%s_installer.zip", appBaseName, version, arch), nil + default: + return "", fmt.Errorf("unknown artifact type %q", artifactType) + } +} + +func newContext(packagesDir, chromiumSrc, arch, buildType string) (*Context, error) { + chromiumVersion, versionParts, err := loadChromiumVersion(packagesDir) + if err != nil { + return nil, err + } + buildOffset, err := loadBuildOffset(packagesDir) + if err != nil { + return nil, err + } + semanticVersion, err := loadSemanticVersion(packagesDir) + if err != nil { + return nil, err + } + + outDir := filepath.Join("out", "Default_"+arch) + + browserOSChromiumVersion := "" + if len(versionParts) == 4 && buildOffset != "" { + baseBuild, err := strconv.Atoi(versionParts[2]) + if err != nil { + return nil, fmt.Errorf("parsing chromium BUILD value %q: %w", versionParts[2], err) + } + offsetInt, err := strconv.Atoi(buildOffset) + if err != nil { + return nil, fmt.Errorf("parsing BROWSEROS_BUILD_OFFSET %q: %w", buildOffset, err) + } + browserOSChromiumVersion = fmt.Sprintf("%s.%s.%d.%s", versionParts[0], versionParts[1], baseBuild+offsetInt, versionParts[3]) + } + + sparkleVersion := "" + if browserOSChromiumVersion != "" { + parts := strings.Split(browserOSChromiumVersion, ".") + if len(parts) >= 4 { + sparkleVersion = fmt.Sprintf("%s.%s", parts[2], parts[3]) + } + } + + return &Context{ + PackagesDir: packagesDir, + ChromiumSrc: chromiumSrc, + Architecture: arch, + BuildType: buildType, + OutDir: outDir, + ChromiumVersion: chromiumVersion, + BrowserOSBuildOffset: buildOffset, + BrowserOSChromiumVersion: browserOSChromiumVersion, + SemanticVersion: semanticVersion, + SparkleVersion: defaultString(sparkleVersion, "0.0"), + SparkleSignatures: map[string]SparkleSignature{}, + Env: nativecommon.LoadEnv(packagesDir), + }, nil +} + +func loadChromiumVersion(packagesDir string) (string, []string, error) { + path := filepath.Join(packagesDir, "CHROMIUM_VERSION") + data, err := os.ReadFile(path) + if err != nil { + return "", nil, fmt.Errorf("reading CHROMIUM_VERSION: %w", err) + } + + var major, minor, build, patch string + for _, line := range strings.Split(strings.TrimSpace(string(data)), "\n") { + line = strings.TrimSpace(line) + if line == "" || !strings.Contains(line, "=") { + continue + } + parts := strings.SplitN(line, "=", 2) + key := strings.TrimSpace(parts[0]) + val := strings.TrimSpace(parts[1]) + switch key { + case "MAJOR": + major = val + case "MINOR": + minor = val + case "BUILD": + build = val + case "PATCH": + patch = val + } + } + if major == "" || minor == "" || build == "" || patch == "" { + return "", nil, fmt.Errorf("invalid CHROMIUM_VERSION format in %s", path) + } + return fmt.Sprintf("%s.%s.%s.%s", major, minor, build, patch), []string{major, minor, build, patch}, nil +} + +func loadBuildOffset(packagesDir string) (string, error) { + path := filepath.Join(packagesDir, "build", "config", "BROWSEROS_BUILD_OFFSET") + data, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("reading BROWSEROS_BUILD_OFFSET: %w", err) + } + return strings.TrimSpace(string(data)), nil +} + +func loadSemanticVersion(packagesDir string) (string, error) { + path := filepath.Join(packagesDir, "resources", "BROWSEROS_VERSION") + data, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("reading resources/BROWSEROS_VERSION: %w", err) + } + + values := map[string]string{} + for _, line := range strings.Split(strings.TrimSpace(string(data)), "\n") { + line = strings.TrimSpace(line) + if line == "" || !strings.Contains(line, "=") { + continue + } + parts := strings.SplitN(line, "=", 2) + values[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1]) + } + + major := defaultString(values["BROWSEROS_MAJOR"], "0") + minor := defaultString(values["BROWSEROS_MINOR"], "0") + build := defaultString(values["BROWSEROS_BUILD"], "0") + patch := defaultString(values["BROWSEROS_PATCH"], "0") + + if patch != "0" { + return fmt.Sprintf("%s.%s.%s.%s", major, minor, build, patch), nil + } + if build != "0" { + return fmt.Sprintf("%s.%s.%s", major, minor, build), nil + } + return fmt.Sprintf("%s.%s.0", major, minor), nil +} + +func defaultString(value, fallback string) string { + if strings.TrimSpace(value) == "" { + return fallback + } + return value +} + +func fileExists(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} + +func dirExists(path string) bool { + info, err := os.Stat(path) + return err == nil && info.IsDir() +} + +func currentPlatformName() string { + switch runtime.GOOS { + case "darwin": + return "macos" + case "windows": + return "windows" + default: + return "linux" + } +} diff --git a/packages/browseros/tools/bros/internal/native/build/exec_helpers.go b/packages/browseros/tools/bros/internal/native/build/exec_helpers.go new file mode 100644 index 00000000..d6509864 --- /dev/null +++ b/packages/browseros/tools/bros/internal/native/build/exec_helpers.go @@ -0,0 +1,65 @@ +package build + +import ( + "bytes" + "errors" + "fmt" + "os" + "os/exec" + "strings" +) + +type cmdResult struct { + Stdout string + Stderr string + ExitCode int +} + +func runCmdCapture(dir string, name string, args ...string) (cmdResult, error) { + cmd := exec.Command(name, args...) + if strings.TrimSpace(dir) != "" { + cmd.Dir = dir + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + result := cmdResult{ + Stdout: strings.TrimSpace(stdout.String()), + Stderr: strings.TrimSpace(stderr.String()), + } + + if err == nil { + result.ExitCode = 0 + return result, nil + } + + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + result.ExitCode = exitErr.ExitCode() + return result, nil + } + + return result, fmt.Errorf("running %s %s: %w", name, strings.Join(args, " "), err) +} + +func runCmdWithEnv(dir string, extraEnv map[string]string, name string, args ...string) error { + cmd := exec.Command(name, args...) + if strings.TrimSpace(dir) != "" { + cmd.Dir = dir + } + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin + cmd.Env = os.Environ() + for key, value := range extraEnv { + cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", key, value)) + } + if err := cmd.Run(); err != nil { + return fmt.Errorf("%s %s: %w", name, strings.Join(args, " "), err) + } + return nil +} diff --git a/packages/browseros/tools/bros/internal/native/build/modules.go b/packages/browseros/tools/bros/internal/native/build/modules.go new file mode 100644 index 00000000..041d04f7 --- /dev/null +++ b/packages/browseros/tools/bros/internal/native/build/modules.go @@ -0,0 +1,1033 @@ +package build + +import ( + "context" + "encoding/json" + "encoding/xml" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "sort" + "strings" + "time" + + brosconfig "bros/internal/config" + "bros/internal/engine" + + "github.com/aws/aws-sdk-go-v2/aws" + awsconfig "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/aws/aws-sdk-go-v2/service/s3/types" + "gopkg.in/yaml.v3" +) + +type downloadConfig struct { + DownloadOperations []downloadOperation `yaml:"download_operations"` +} + +type downloadOperation struct { + Name string `yaml:"name"` + R2Key string `yaml:"r2_key"` + Destination string `yaml:"destination"` + OS []string `yaml:"os"` + Arch []string `yaml:"arch"` + BuildType string `yaml:"build_type"` + Executable bool `yaml:"executable"` +} + +type copyConfig struct { + CopyOperations []copyOperation `yaml:"copy_operations"` +} + +type copyOperation struct { + Name string `yaml:"name"` + Source string `yaml:"source"` + Destination string `yaml:"destination"` + Type string `yaml:"type"` + BuildType string `yaml:"build_type"` + OS []string `yaml:"os"` + Arch []string `yaml:"arch"` +} + +var brandingReplacements = [][2]string{ + {"The Chromium Authors. All rights reserved.", "The BrowserOS Authors. All rights reserved."}, + {"Google LLC. All rights reserved.", "The BrowserOS Authors. All rights reserved."}, + {"The Chromium Authors", "BrowserOS Software Inc"}, + {"Google Chrome", "BrowserOS"}, + {"Chromium", "BrowserOS"}, + {"Chrome", "BrowserOS"}, +} + +func runClean(ctx *Context) error { + if !dirExists(ctx.ChromiumSrc) { + return fmt.Errorf("chromium source not found: %s", ctx.ChromiumSrc) + } + + outPath := filepath.Join(ctx.ChromiumSrc, ctx.OutDir) + if dirExists(outPath) { + if err := os.RemoveAll(outPath); err != nil { + return fmt.Errorf("cleaning out dir: %w", err) + } + } + + if err := runCmd(ctx.ChromiumSrc, "git", "reset", "--hard", "HEAD"); err != nil { + return err + } + if err := runCmd( + ctx.ChromiumSrc, "git", "clean", "-fdx", "chrome/", "components/", + "--exclude=third_party/", "--exclude=build_tools/", "--exclude=uc_staging/", + "--exclude=buildtools/", "--exclude=tools/", "--exclude=build/", + ); err != nil { + return err + } + + sparkleDir := ctx.SparkleDir() + if dirExists(sparkleDir) { + if err := os.RemoveAll(sparkleDir); err != nil { + return fmt.Errorf("cleaning sparkle dir: %w", err) + } + } + + return nil +} + +func runGitSetup(ctx *Context) error { + if !dirExists(ctx.ChromiumSrc) { + return fmt.Errorf("chromium source not found: %s", ctx.ChromiumSrc) + } + if strings.TrimSpace(ctx.ChromiumVersion) == "" { + return fmt.Errorf("chromium version is not set") + } + + if err := runCmd(ctx.ChromiumSrc, "git", "fetch", "--tags", "--force"); err != nil { + return err + } + + tagExists, err := gitTagExists(ctx.ChromiumSrc, ctx.ChromiumVersion) + if err != nil { + return err + } + if !tagExists { + return fmt.Errorf("git tag %s not found", ctx.ChromiumVersion) + } + + if err := runCmd(ctx.ChromiumSrc, "git", "checkout", "tags/"+ctx.ChromiumVersion); err != nil { + return err + } + + gclient := "gclient" + if runtime.GOOS == "windows" { + gclient = "gclient.bat" + } + return runCmd(ctx.ChromiumSrc, gclient, "sync", "-D", "--no-history", "--shallow") +} + +func runSparkleSetup(ctx *Context) error { + if runtime.GOOS != "darwin" { + return fmt.Errorf("sparkle_setup requires macOS") + } + + sparkleDir := ctx.SparkleDir() + _ = os.RemoveAll(sparkleDir) + if err := os.MkdirAll(sparkleDir, 0o755); err != nil { + return fmt.Errorf("creating sparkle dir: %w", err) + } + + archivePath := filepath.Join(sparkleDir, "sparkle.tar.xz") + if err := downloadFile(ctx.SparkleURL(), archivePath); err != nil { + return fmt.Errorf("downloading sparkle archive: %w", err) + } + defer os.Remove(archivePath) + + return runCmd("", "tar", "-xJf", archivePath, "-C", sparkleDir) +} + +func runConfigure(ctx *Context) error { + if !dirExists(ctx.ChromiumSrc) { + return fmt.Errorf("chromium source not found: %s", ctx.ChromiumSrc) + } + + flagsPath := ctx.GNFlagsPath() + data, err := os.ReadFile(flagsPath) + if err != nil { + return fmt.Errorf("reading GN flags file %s: %w", flagsPath, err) + } + + outPath := filepath.Join(ctx.ChromiumSrc, ctx.OutDir) + if err := os.MkdirAll(outPath, 0o755); err != nil { + return fmt.Errorf("creating out dir: %w", err) + } + + argsPath := ctx.GNArgsPath() + argsContent := string(data) + fmt.Sprintf("\ntarget_cpu = %q\n", ctx.Architecture) + if err := os.WriteFile(argsPath, []byte(argsContent), 0o644); err != nil { + return fmt.Errorf("writing args.gn: %w", err) + } + + gn := "gn" + if runtime.GOOS == "windows" { + gn = "gn.bat" + } + return runCmd(ctx.ChromiumSrc, gn, "gen", ctx.OutDir, "--fail-on-unused-args") +} + +func runDownloadResources(ctx *Context) error { + cfg, err := loadDownloadConfig(ctx.DownloadResourcesConfig()) + if err != nil { + return err + } + filtered := filterDownloadOps(cfg.DownloadOperations, ctx) + if len(filtered) == 0 { + return nil + } + + r2cfg, err := loadR2Config() + if err != nil { + return err + } + client, err := newR2Client(r2cfg) + if err != nil { + return err + } + + for _, op := range filtered { + dest := filepath.Join(ctx.PackagesDir, op.Destination) + _ = os.Remove(dest) + if err := downloadR2Object(client, r2cfg.Bucket, op.R2Key, dest); err != nil { + return fmt.Errorf("%s: %w", op.Name, err) + } + if op.Executable { + if err := os.Chmod(dest, 0o755); err != nil { + return fmt.Errorf("setting executable bit on %s: %w", dest, err) + } + } + } + + return nil +} + +func runResources(ctx *Context) error { + data, err := os.ReadFile(ctx.CopyResourcesConfig()) + if err != nil { + return fmt.Errorf("reading copy_resources config: %w", err) + } + var cfg copyConfig + if err := yaml.Unmarshal(data, &cfg); err != nil { + return fmt.Errorf("parsing copy_resources config: %w", err) + } + + for _, op := range cfg.CopyOperations { + if !copyOpMatches(op, ctx) { + continue + } + + src := filepath.Join(ctx.PackagesDir, op.Source) + dst := filepath.Join(ctx.ChromiumSrc, op.Destination) + switch op.Type { + case "directory": + if !dirExists(src) { + continue + } + if err := copyDir(src, dst); err != nil { + return fmt.Errorf("%s: %w", op.Name, err) + } + case "files": + matches, err := filepath.Glob(src) + if err != nil { + return fmt.Errorf("%s: invalid glob %s: %w", op.Name, src, err) + } + if err := os.MkdirAll(dst, 0o755); err != nil { + return fmt.Errorf("%s: creating destination dir: %w", op.Name, err) + } + for _, match := range matches { + info, err := os.Stat(match) + if err != nil || info.IsDir() { + continue + } + if err := copyFile(match, filepath.Join(dst, filepath.Base(match))); err != nil { + return fmt.Errorf("%s: %w", op.Name, err) + } + } + case "file": + if !fileExists(src) { + continue + } + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + return fmt.Errorf("%s: creating destination parent: %w", op.Name, err) + } + if err := copyFile(src, dst); err != nil { + return fmt.Errorf("%s: %w", op.Name, err) + } + default: + return fmt.Errorf("%s: unsupported copy operation type %q", op.Name, op.Type) + } + } + + return nil +} + +func runBundledExtensions(ctx *Context) error { + manifestURL := "https://cdn.browseros.com/extensions/update-manifest.xml" + manifest, err := fetchExtensionManifest(manifestURL) + if err != nil { + return err + } + if len(manifest) == 0 { + return fmt.Errorf("no extensions found in %s", manifestURL) + } + + outputDir := filepath.Join(ctx.ChromiumSrc, "chrome", "browser", "browseros", "bundled_extensions") + if err := os.MkdirAll(outputDir, 0o755); err != nil { + return fmt.Errorf("creating bundled_extensions dir: %w", err) + } + + jsonOutput := map[string]map[string]string{} + for _, ext := range manifest { + dest := filepath.Join(outputDir, ext.ID+".crx") + if err := downloadFile(ext.Codebase, dest); err != nil { + return fmt.Errorf("downloading extension %s: %w", ext.ID, err) + } + jsonOutput[ext.ID] = map[string]string{ + "external_crx": ext.ID + ".crx", + "external_version": ext.Version, + } + } + + jsonPath := filepath.Join(outputDir, "bundled_extensions.json") + data, err := json.MarshalIndent(jsonOutput, "", " ") + if err != nil { + return fmt.Errorf("encoding bundled_extensions.json: %w", err) + } + data = append(data, '\n') + return os.WriteFile(jsonPath, data, 0o644) +} + +func runChromiumReplace(ctx *Context) error { + replacementDir := ctx.ChromiumFilesDir() + if !dirExists(replacementDir) { + return nil + } + + return filepath.WalkDir(replacementDir, func(path string, d os.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + + rel, err := filepath.Rel(replacementDir, path) + if err != nil { + return err + } + rel = filepath.ToSlash(rel) + + ext := filepath.Ext(rel) + if ext == ".debug" || ext == ".release" { + if (ctx.BuildType == "debug" && ext != ".debug") || (ctx.BuildType == "release" && ext != ".release") { + return nil + } + rel = strings.TrimSuffix(rel, ext) + } else { + base := strings.TrimSuffix(rel, filepath.Ext(rel)) + if ctx.BuildType == "debug" && fileExists(filepath.Join(replacementDir, base+filepath.Ext(rel)+".debug")) { + return nil + } + if ctx.BuildType == "release" && fileExists(filepath.Join(replacementDir, base+filepath.Ext(rel)+".release")) { + return nil + } + } + + dest := filepath.Join(ctx.ChromiumSrc, filepath.FromSlash(rel)) + if !fileExists(dest) { + return fmt.Errorf("destination file not found in chromium source: %s", rel) + } + return copyFile(path, dest) + }) +} + +func runStringReplaces(ctx *Context) error { + targets := []string{ + filepath.Join(ctx.ChromiumSrc, "chrome", "app", "chromium_strings.grd"), + filepath.Join(ctx.ChromiumSrc, "chrome", "app", "settings_chromium_strings.grdp"), + } + + for _, filePath := range targets { + if !fileExists(filePath) { + continue + } + data, err := os.ReadFile(filePath) + if err != nil { + return fmt.Errorf("reading %s: %w", filePath, err) + } + content := string(data) + + for _, repl := range brandingReplacements { + content = strings.ReplaceAll(content, repl[0], repl[1]) + } + content = replaceGoogleNotPlay(content) + + if err := os.WriteFile(filePath, []byte(content), 0o644); err != nil { + return fmt.Errorf("writing %s: %w", filePath, err) + } + } + return nil +} + +func runPatches(ctx *Context) error { + baseCommit, err := brosconfig.ReadBaseCommit(ctx.PackagesDir) + if err != nil { + return err + } + pctx := &brosconfig.Context{ + Config: &brosconfig.Config{Name: "build", PatchesRepo: ctx.PackagesDir}, + State: &brosconfig.State{}, + ChromiumDir: ctx.ChromiumSrc, + PatchesRepo: ctx.PackagesDir, + PatchesDir: ctx.PatchesDir(), + BaseCommit: baseCommit, + } + result, err := engine.Clone(pctx, engine.CloneOpts{ + VerifyBase: false, + Clean: false, + DryRun: false, + }) + if err != nil { + return err + } + if len(result.Conflicts) > 0 { + return fmt.Errorf("%d patch conflicts", len(result.Conflicts)) + } + return nil +} + +func runSeriesPatches(ctx *Context) error { + seriesDir := ctx.SeriesPatchesDir() + if !dirExists(seriesDir) { + return fmt.Errorf("series_patches directory not found: %s", seriesDir) + } + + seriesFiles := []string{ + filepath.Join(seriesDir, "series"), + filepath.Join(seriesDir, "series."+currentPlatformName()), + } + + var patchPaths []string + for _, seriesFile := range seriesFiles { + if !fileExists(seriesFile) { + continue + } + lines, err := os.ReadFile(seriesFile) + if err != nil { + return fmt.Errorf("reading %s: %w", seriesFile, err) + } + for _, line := range strings.Split(string(lines), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + if strings.Contains(line, " #") { + line = strings.TrimSpace(strings.Split(line, " #")[0]) + } + if line == "" { + continue + } + patchPaths = append(patchPaths, filepath.Join(seriesDir, filepath.FromSlash(line))) + } + } + + for _, patchPath := range patchPaths { + if !fileExists(patchPath) { + return fmt.Errorf("series patch not found: %s", patchPath) + } + err := runCmd(ctx.ChromiumSrc, "git", "apply", "--ignore-whitespace", "--whitespace=nowarn", "-p1", patchPath) + if err != nil { + err = runCmd(ctx.ChromiumSrc, "git", "apply", "--3way", "--ignore-whitespace", "--whitespace=nowarn", "-p1", patchPath) + } + if err != nil { + return fmt.Errorf("failed applying series patch %s: %w", patchPath, err) + } + } + return nil +} + +func runCompile(ctx *Context) error { + if !fileExists(ctx.GNArgsPath()) { + return fmt.Errorf("build not configured: missing %s", ctx.GNArgsPath()) + } + if strings.TrimSpace(ctx.BrowserOSChromiumVersion) == "" { + return fmt.Errorf("browseros chromium version is not set") + } + + if err := writeChromeVersion(ctx); err != nil { + return err + } + + autoninja := "autoninja" + if runtime.GOOS == "windows" { + autoninja = "autoninja.bat" + } + if err := runCmd(ctx.ChromiumSrc, autoninja, "-C", ctx.OutDir, "chrome", "chromedriver"); err != nil { + return err + } + + chromiumApp := ctx.ChromiumAppPath() + browserOSApp := ctx.BrowserOSAppPath() + if fileExists(chromiumApp) && !fileExists(browserOSApp) { + if err := os.Rename(chromiumApp, browserOSApp); err != nil { + return fmt.Errorf("renaming %s to %s: %w", chromiumApp, browserOSApp, err) + } + } + return nil +} + +func runSignLinux(ctx *Context) error { + _ = ctx + return nil +} + +func runUpload(ctx *Context) error { + artifacts, platform := detectArtifacts(ctx) + if len(artifacts) == 0 { + return nil + } + + r2cfg, err := loadR2Config() + if err != nil { + return err + } + client, err := newR2Client(r2cfg) + if err != nil { + return err + } + + releasePath := ctx.ReleasePath(platform) + for _, artifact := range artifacts { + key := releasePath + filepath.Base(artifact.Path) + if err := uploadR2Object(client, r2cfg.Bucket, key, artifact.Path); err != nil { + return fmt.Errorf("uploading %s: %w", artifact.Path, err) + } + } + + releaseJSON := buildReleaseJSON(ctx, platform, artifacts, r2cfg.CDNBaseURL) + jsonData, err := json.MarshalIndent(releaseJSON, "", " ") + if err != nil { + return fmt.Errorf("encoding release.json: %w", err) + } + + distDir := ctx.DistDir() + if err := os.MkdirAll(distDir, 0o755); err != nil { + return fmt.Errorf("creating dist dir: %w", err) + } + localReleaseJSON := filepath.Join(distDir, "release.json") + if err := os.WriteFile(localReleaseJSON, append(jsonData, '\n'), 0o644); err != nil { + return fmt.Errorf("writing local release.json: %w", err) + } + if err := uploadR2Object(client, r2cfg.Bucket, releasePath+"release.json", localReleaseJSON); err != nil { + return fmt.Errorf("uploading release.json: %w", err) + } + + return nil +} + +type r2Config struct { + AccountID string + AccessKeyID string + SecretAccessKey string + Bucket string + CDNBaseURL string + Endpoint string +} + +func loadR2Config() (*r2Config, error) { + accountID := strings.TrimSpace(os.Getenv("R2_ACCOUNT_ID")) + access := strings.TrimSpace(os.Getenv("R2_ACCESS_KEY_ID")) + secret := strings.TrimSpace(os.Getenv("R2_SECRET_ACCESS_KEY")) + if accountID == "" || access == "" || secret == "" { + return nil, fmt.Errorf("R2 configuration not set. Required env vars: R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY") + } + bucket := strings.TrimSpace(os.Getenv("R2_BUCKET")) + if bucket == "" { + bucket = "browseros" + } + cdnBase := strings.TrimSpace(os.Getenv("R2_CDN_BASE_URL")) + if cdnBase == "" { + cdnBase = "http://cdn.browseros.com" + } + return &r2Config{ + AccountID: accountID, + AccessKeyID: access, + SecretAccessKey: secret, + Bucket: bucket, + CDNBaseURL: strings.TrimRight(cdnBase, "/"), + Endpoint: fmt.Sprintf("https://%s.r2.cloudflarestorage.com", accountID), + }, nil +} + +func newR2Client(cfg *r2Config) (*s3.Client, error) { + awsCfg, err := awsconfig.LoadDefaultConfig( + context.Background(), + awsconfig.WithRegion("auto"), + awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(cfg.AccessKeyID, cfg.SecretAccessKey, "")), + ) + if err != nil { + return nil, fmt.Errorf("loading AWS config: %w", err) + } + return s3.NewFromConfig(awsCfg, func(o *s3.Options) { + o.BaseEndpoint = aws.String(cfg.Endpoint) + o.UsePathStyle = true + }), nil +} + +func uploadR2Object(client *s3.Client, bucket, key, localPath string) error { + f, err := os.Open(localPath) + if err != nil { + return err + } + defer f.Close() + + _, err = client.PutObject(context.Background(), &s3.PutObjectInput{ + Bucket: aws.String(bucket), + Key: aws.String(key), + Body: f, + }) + return err +} + +func downloadR2Object(client *s3.Client, bucket, key, localPath string) error { + out, err := client.GetObject(context.Background(), &s3.GetObjectInput{ + Bucket: aws.String(bucket), + Key: aws.String(key), + }) + if err != nil { + return err + } + defer out.Body.Close() + + if err := os.MkdirAll(filepath.Dir(localPath), 0o755); err != nil { + return err + } + tmpPath := localPath + ".tmp" + f, err := os.Create(tmpPath) + if err != nil { + return err + } + if _, err := io.Copy(f, out.Body); err != nil { + f.Close() + _ = os.Remove(tmpPath) + return err + } + if err := f.Close(); err != nil { + _ = os.Remove(tmpPath) + return err + } + if err := os.Rename(tmpPath, localPath); err != nil { + _ = os.Remove(tmpPath) + return err + } + return nil +} + +func loadDownloadConfig(path string) (*downloadConfig, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading download config %s: %w", path, err) + } + var cfg downloadConfig + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("parsing download config %s: %w", path, err) + } + return &cfg, nil +} + +func filterDownloadOps(ops []downloadOperation, ctx *Context) []downloadOperation { + currentOS := currentPlatformName() + targetArchs := []string{ctx.Architecture} + if ctx.Architecture == "universal" { + targetArchs = []string{"arm64", "x64", "universal"} + } + out := make([]downloadOperation, 0, len(ops)) + for _, op := range ops { + if len(op.OS) > 0 && !containsString(op.OS, currentOS) { + continue + } + if len(op.Arch) > 0 { + matched := false + for _, arch := range targetArchs { + if containsString(op.Arch, arch) { + matched = true + break + } + } + if !matched { + continue + } + } + if strings.TrimSpace(op.BuildType) != "" && strings.TrimSpace(op.BuildType) != ctx.BuildType { + continue + } + out = append(out, op) + } + return out +} + +func copyOpMatches(op copyOperation, ctx *Context) bool { + if strings.TrimSpace(op.BuildType) != "" && strings.TrimSpace(op.BuildType) != ctx.BuildType { + return false + } + if len(op.OS) > 0 && !containsString(op.OS, currentPlatformName()) { + return false + } + if len(op.Arch) > 0 && !containsString(op.Arch, ctx.Architecture) { + return false + } + return true +} + +func copyFile(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + return err + } + + tmp := dst + ".tmp" + out, err := os.Create(tmp) + if err != nil { + return err + } + if _, err := io.Copy(out, in); err != nil { + out.Close() + _ = os.Remove(tmp) + return err + } + if err := out.Close(); err != nil { + _ = os.Remove(tmp) + return err + } + if err := os.Rename(tmp, dst); err != nil { + _ = os.Remove(tmp) + return err + } + return nil +} + +func copyDir(src, dst string) error { + return filepath.WalkDir(src, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(src, path) + if err != nil { + return err + } + target := filepath.Join(dst, rel) + if d.IsDir() { + return os.MkdirAll(target, 0o755) + } + return copyFile(path, target) + }) +} + +func runCmd(dir string, name string, args ...string) error { + cmd := exec.Command(name, args...) + if strings.TrimSpace(dir) != "" { + cmd.Dir = dir + } + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin + if err := cmd.Run(); err != nil { + return fmt.Errorf("%s %s: %w", name, strings.Join(args, " "), err) + } + return nil +} + +func gitTagExists(repoDir, tag string) (bool, error) { + out, err := exec.Command("git", "-C", repoDir, "tag", "-l", tag).CombinedOutput() + if err != nil { + return false, fmt.Errorf("checking tag %s: %w (%s)", tag, err, strings.TrimSpace(string(out))) + } + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + if strings.TrimSpace(line) == tag { + return true, nil + } + } + return false, nil +} + +func writeChromeVersion(ctx *Context) error { + parts := strings.Split(ctx.BrowserOSChromiumVersion, ".") + if len(parts) != 4 { + return fmt.Errorf("invalid browseros chromium version %q", ctx.BrowserOSChromiumVersion) + } + content := fmt.Sprintf("MAJOR=%s\nMINOR=%s\nBUILD=%s\nPATCH=%s", parts[0], parts[1], parts[2], parts[3]) + path := filepath.Join(ctx.ChromiumSrc, "chrome", "VERSION") + return os.WriteFile(path, []byte(content), 0o644) +} + +func replaceGoogleNotPlay(content string) string { + if !strings.Contains(content, "Google") { + return content + } + var b strings.Builder + for i := 0; i < len(content); { + if strings.HasPrefix(content[i:], "Google") { + if strings.HasPrefix(content[i+len("Google"):], " Play") { + b.WriteString("Google") + } else { + b.WriteString("BrowserOS") + } + i += len("Google") + continue + } + b.WriteByte(content[i]) + i++ + } + return b.String() +} + +type manifestDoc struct { + Apps []manifestApp `xml:"app"` +} + +type manifestApp struct { + AppID string `xml:"appid,attr"` + Update manifestUpdate `xml:"updatecheck"` +} + +type manifestUpdate struct { + Version string `xml:"version,attr"` + Codebase string `xml:"codebase,attr"` +} + +type extensionInfo struct { + ID string + Version string + Codebase string +} + +func fetchExtensionManifest(url string) ([]extensionInfo, error) { + resp, err := http.Get(url) + if err != nil { + return nil, fmt.Errorf("fetching extensions manifest: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("fetching extensions manifest: HTTP %d", resp.StatusCode) + } + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("reading extensions manifest: %w", err) + } + + // Namespace-safe fallback: decode by scanning app and updatecheck tags. + var decoded struct { + Apps []manifestApp `xml:"app"` + } + if err := xml.Unmarshal(data, &decoded); err == nil && len(decoded.Apps) > 0 { + out := make([]extensionInfo, 0, len(decoded.Apps)) + for _, app := range decoded.Apps { + if strings.TrimSpace(app.AppID) == "" || strings.TrimSpace(app.Update.Codebase) == "" || strings.TrimSpace(app.Update.Version) == "" { + continue + } + out = append(out, extensionInfo{ID: app.AppID, Version: app.Update.Version, Codebase: app.Update.Codebase}) + } + return out, nil + } + + // Regex fallback for namespaced XML. + re := regexp.MustCompile(`(?s)]*appid=['"]([^'"]+)['"][^>]*>.*?]*codebase=['"]([^'"]+)['"][^>]*version=['"]([^'"]+)['"][^>]*/?>`) + matches := re.FindAllStringSubmatch(string(data), -1) + out := make([]extensionInfo, 0, len(matches)) + for _, m := range matches { + out = append(out, extensionInfo{ + ID: strings.TrimSpace(m[1]), + Codebase: strings.TrimSpace(m[2]), + Version: strings.TrimSpace(m[3]), + }) + } + return out, nil +} + +func downloadFile(url, dest string) error { + resp, err := http.Get(url) + if err != nil { + return fmt.Errorf("GET %s: %w", url, err) + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("GET %s: HTTP %d", url, resp.StatusCode) + } + if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { + return err + } + tmp := dest + ".tmp" + f, err := os.Create(tmp) + if err != nil { + return err + } + if _, err := io.Copy(f, resp.Body); err != nil { + f.Close() + _ = os.Remove(tmp) + return err + } + if err := f.Close(); err != nil { + _ = os.Remove(tmp) + return err + } + if err := os.Rename(tmp, dest); err != nil { + _ = os.Remove(tmp) + return err + } + return nil +} + +type localArtifact struct { + Path string + Key string + Size int64 + Metadata map[string]any +} + +func detectArtifacts(ctx *Context) ([]localArtifact, string) { + distDir := ctx.DistDir() + if !dirExists(distDir) { + return nil, platformForR2() + } + + entries, err := os.ReadDir(distDir) + if err != nil { + return nil, platformForR2() + } + + var artifacts []localArtifact + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + ext := strings.ToLower(filepath.Ext(name)) + keep := false + switch platformForR2() { + case "macos": + keep = ext == ".dmg" + case "win": + keep = ext == ".exe" || ext == ".zip" + default: + keep = ext == ".appimage" || ext == ".deb" + } + if !keep { + continue + } + fullPath := filepath.Join(distDir, name) + info, err := os.Stat(fullPath) + if err != nil { + continue + } + artifacts = append(artifacts, localArtifact{ + Path: fullPath, + Key: artifactKey(name, platformForR2()), + Size: info.Size(), + Metadata: artifactMetadataForFile(ctx, name), + }) + } + sort.Slice(artifacts, func(i, j int) bool { return artifacts[i].Path < artifacts[j].Path }) + return artifacts, platformForR2() +} + +func artifactKey(filename, platform string) string { + lower := strings.ToLower(filename) + switch platform { + case "macos": + switch { + case strings.Contains(lower, "arm64"): + return "arm64" + case strings.Contains(lower, "x64") || strings.Contains(lower, "x86_64"): + return "x64" + case strings.Contains(lower, "universal"): + return "universal" + } + case "win": + switch { + case strings.Contains(lower, "installer.exe"): + return "x64_installer" + case strings.Contains(lower, "installer.zip"): + return "x64_zip" + } + default: + switch { + case strings.HasSuffix(lower, ".appimage"): + return "x64_appimage" + case strings.HasSuffix(lower, ".deb"): + return "x64_deb" + } + } + return strings.TrimSuffix(filename, filepath.Ext(filename)) +} + +func platformForR2() string { + switch runtime.GOOS { + case "darwin": + return "macos" + case "windows": + return "win" + default: + return "linux" + } +} + +func buildReleaseJSON(ctx *Context, platform string, artifacts []localArtifact, cdnBase string) map[string]any { + releasePath := strings.TrimLeft(ctx.ReleasePath(platform), "/") + obj := map[string]any{ + "platform": platform, + "version": ctx.SemanticVersion, + "chromium_version": ctx.ChromiumVersion, + "browseros_chromium_version": ctx.BrowserOSChromiumVersion, + "build_date": time.Now().UTC().Format(time.RFC3339), + "artifacts": map[string]any{}, + } + if platform == "macos" && strings.TrimSpace(ctx.SparkleVersion) != "" { + obj["sparkle_version"] = ctx.SparkleVersion + } + artMap := obj["artifacts"].(map[string]any) + for _, art := range artifacts { + filename := filepath.Base(art.Path) + entry := map[string]any{ + "filename": filename, + "url": strings.TrimRight(cdnBase, "/") + "/" + releasePath + filename, + "size": art.Size, + } + for k, v := range art.Metadata { + entry[k] = v + } + artMap[art.Key] = entry + } + return obj +} + +func artifactMetadataForFile(ctx *Context, filename string) map[string]any { + out := map[string]any{} + if sig, ok := ctx.SparkleSignatures[filename]; ok { + out["sparkle_signature"] = sig.Signature + out["sparkle_length"] = sig.Length + } + return out +} + +func copyR2Object(client *s3.Client, bucket, sourceKey, destKey string) error { + _, err := client.CopyObject(context.Background(), &s3.CopyObjectInput{ + Bucket: aws.String(bucket), + CopySource: aws.String(bucket + "/" + sourceKey), + Key: aws.String(destKey), + MetadataDirective: types.MetadataDirectiveCopy, + }) + return err +} diff --git a/packages/browseros/tools/bros/internal/native/build/package_linux_impl.go b/packages/browseros/tools/bros/internal/native/build/package_linux_impl.go new file mode 100644 index 00000000..b1b7b252 --- /dev/null +++ b/packages/browseros/tools/bros/internal/native/build/package_linux_impl.go @@ -0,0 +1,402 @@ +package build + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" +) + +func runPackageLinux(ctx *Context) error { + if runtime.GOOS != "linux" { + return fmt.Errorf("Linux packaging requires Linux") + } + + browserBinary := ctx.BrowserOSAppPath() + if !fileExists(browserBinary) { + return fmt.Errorf("Chrome binary not found: %s", browserBinary) + } + + packageDir := ctx.DistDir() + if err := os.MkdirAll(packageDir, 0o755); err != nil { + return fmt.Errorf("creating dist dir: %w", err) + } + + appImagePath, appImageErr := packageLinuxAppImage(ctx, packageDir) + debPath, debErr := packageLinuxDeb(ctx, packageDir) + + if appImagePath == "" && debPath == "" { + return fmt.Errorf("both AppImage and .deb packaging failed: appimage=%v, deb=%v", appImageErr, debErr) + } + + return nil +} + +func packageLinuxAppImage(ctx *Context, packageDir string) (string, error) { + appDir := filepath.Join(packageDir, strings.ToLower(appBaseName)+".AppDir") + _ = os.RemoveAll(appDir) + + if err := prepareLinuxAppDir(ctx, appDir); err != nil { + _ = os.RemoveAll(appDir) + return "", err + } + + filename, err := ctx.ArtifactName("appimage") + if err != nil { + _ = os.RemoveAll(appDir) + return "", err + } + outputPath := filepath.Join(packageDir, filename) + + if err := createLinuxAppImage(ctx, appDir, outputPath); err != nil { + _ = os.RemoveAll(appDir) + return "", err + } + + _ = os.RemoveAll(appDir) + return outputPath, nil +} + +func packageLinuxDeb(ctx *Context, packageDir string) (string, error) { + debDir := filepath.Join(packageDir, strings.ToLower(appBaseName)+"_deb") + _ = os.RemoveAll(debDir) + + if err := prepareLinuxDebDir(ctx, debDir); err != nil { + _ = os.RemoveAll(debDir) + return "", err + } + + filename, err := ctx.ArtifactName("deb") + if err != nil { + _ = os.RemoveAll(debDir) + return "", err + } + outputPath := filepath.Join(packageDir, filename) + + if err := createLinuxDeb(debDir, outputPath); err != nil { + _ = os.RemoveAll(debDir) + return "", err + } + + _ = os.RemoveAll(debDir) + return outputPath, nil +} + +func prepareLinuxAppDir(ctx *Context, appDir string) error { + appRoot := filepath.Join(appDir, "opt", "browseros") + usrShare := filepath.Join(appDir, "usr", "share") + iconsDir := filepath.Join(usrShare, "icons", "hicolor") + appsDir := filepath.Join(usrShare, "applications") + + if err := copyLinuxBrowserFiles(ctx, appRoot, true); err != nil { + return err + } + + desktopPath, err := createLinuxDesktopFile(appsDir, "/opt/browseros/"+strings.ToLower(appBaseName)) + if err != nil { + return err + } + + iconSource := filepath.Join(ctx.PackagesDir, "resources", "icons", "product_logo.png") + _ = copyLinuxIcon(iconSource, iconsDir) + + appDirDesktop := filepath.Join(appDir, "browseros.desktop") + if err := copyFile(desktopPath, appDirDesktop); err != nil { + return err + } + desktopData, err := os.ReadFile(appDirDesktop) + if err != nil { + return err + } + updatedDesktop := strings.ReplaceAll(string(desktopData), "Exec=/opt/browseros/"+strings.ToLower(appBaseName)+" %U", "Exec=AppRun %U") + if err := os.WriteFile(appDirDesktop, []byte(updatedDesktop), 0o644); err != nil { + return err + } + + if fileExists(iconSource) { + if err := copyFile(iconSource, filepath.Join(appDir, "browseros.png")); err != nil { + return err + } + } + + appRunContent := fmt.Sprintf(`#!/bin/sh +THIS="$(readlink -f "${0}")" +HERE="$(dirname "${THIS}")" +export LD_LIBRARY_PATH="${HERE}"/opt/browseros:$LD_LIBRARY_PATH +export CHROME_WRAPPER="${THIS}" +"${HERE}"/opt/browseros/%s "$@" +`, strings.ToLower(appBaseName)) + appRunPath := filepath.Join(appDir, "AppRun") + if err := os.WriteFile(appRunPath, []byte(appRunContent), 0o755); err != nil { + return err + } + + return nil +} + +func createLinuxAppImage(ctx *Context, appDir, outputPath string) error { + toolPath, err := ensureAppImageTool(ctx) + if err != nil { + return err + } + + arch := "x86_64" + if strings.TrimSpace(ctx.Architecture) == "arm64" { + arch = "aarch64" + } + + if err := runCmdWithEnv("", map[string]string{"ARCH": arch}, toolPath, "--comp", "gzip", appDir, outputPath); err != nil { + return err + } + + if err := os.Chmod(outputPath, 0o755); err != nil { + return err + } + + return nil +} + +func ensureAppImageTool(ctx *Context) (string, error) { + toolDir := filepath.Join(ctx.PackagesDir, "build", "tools") + if err := os.MkdirAll(toolDir, 0o755); err != nil { + return "", err + } + + toolPath := filepath.Join(toolDir, "appimagetool-x86_64.AppImage") + if fileExists(toolPath) { + return toolPath, nil + } + + if err := downloadFile( + "https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage", + toolPath, + ); err != nil { + return "", fmt.Errorf("downloading appimagetool: %w", err) + } + + if err := os.Chmod(toolPath, 0o755); err != nil { + return "", err + } + return toolPath, nil +} + +func prepareLinuxDebDir(ctx *Context, debDir string) error { + libDir := filepath.Join(debDir, "usr", "lib", "browseros") + binDir := filepath.Join(debDir, "usr", "bin") + shareDir := filepath.Join(debDir, "usr", "share") + appsDir := filepath.Join(shareDir, "applications") + iconsDir := filepath.Join(shareDir, "icons", "hicolor") + debianDir := filepath.Join(debDir, "DEBIAN") + + if err := copyLinuxBrowserFiles(ctx, libDir, false); err != nil { + return err + } + + if err := createLinuxLauncher(binDir, strings.ToLower(appBaseName)); err != nil { + return err + } + + if _, err := createLinuxDesktopFile(appsDir, "/usr/bin/browseros"); err != nil { + return err + } + + iconSource := filepath.Join(ctx.PackagesDir, "resources", "icons", "product_logo.png") + _ = copyLinuxIcon(iconSource, iconsDir) + + if err := createLinuxControlFile(ctx, debianDir); err != nil { + return err + } + if err := createLinuxPostinstFile(debianDir); err != nil { + return err + } + + return nil +} + +func createLinuxDeb(debDir, outputPath string) error { + if _, err := exec.LookPath("dpkg-deb"); err != nil { + return fmt.Errorf("dpkg-deb not found") + } + + if err := runCmd("", "dpkg-deb", "--build", "--root-owner-group", debDir, outputPath); err != nil { + return fmt.Errorf("building deb: %w", err) + } + + if err := os.Chmod(outputPath, 0o644); err != nil { + return err + } + return nil +} + +func copyLinuxBrowserFiles(ctx *Context, targetDir string, setSandboxSUID bool) error { + if err := os.MkdirAll(targetDir, 0o755); err != nil { + return err + } + + outDir := filepath.Join(ctx.ChromiumSrc, ctx.OutDir) + filesToCopy := []string{ + strings.ToLower(appBaseName), + "chrome_crashpad_handler", + "chrome_sandbox", + "chromedriver", + "libEGL.so", + "libGLESv2.so", + "libvk_swiftshader.so", + "libvulkan.so.1", + "vk_swiftshader_icd.json", + "icudtl.dat", + "snapshot_blob.bin", + "v8_context_snapshot.bin", + "chrome_100_percent.pak", + "chrome_200_percent.pak", + "resources.pak", + } + for _, name := range filesToCopy { + src := filepath.Join(outDir, name) + if !fileExists(src) { + continue + } + if err := copyFile(src, filepath.Join(targetDir, name)); err != nil { + return err + } + } + + dirsToCopy := []string{"locales", "MEIPreload", "BrowserOSServer"} + for _, dirName := range dirsToCopy { + src := filepath.Join(outDir, dirName) + if !dirExists(src) { + continue + } + if err := copyDir(src, filepath.Join(targetDir, dirName)); err != nil { + return err + } + } + + browserPath := filepath.Join(targetDir, strings.ToLower(appBaseName)) + if fileExists(browserPath) { + _ = os.Chmod(browserPath, 0o755) + } + + sandboxPath := filepath.Join(targetDir, "chrome_sandbox") + if fileExists(sandboxPath) { + if setSandboxSUID { + _ = os.Chmod(sandboxPath, 0o4755) + } else { + _ = os.Chmod(sandboxPath, 0o755) + } + } + + crashpadPath := filepath.Join(targetDir, "chrome_crashpad_handler") + if fileExists(crashpadPath) { + _ = os.Chmod(crashpadPath, 0o755) + } + + return nil +} + +func createLinuxDesktopFile(appsDir, execPath string) (string, error) { + if err := os.MkdirAll(appsDir, 0o755); err != nil { + return "", err + } + desktopContent := fmt.Sprintf(`[Desktop Entry] +Version=1.0 +Name=BrowserOS +GenericName=Web Browser +Comment=Browse the World Wide Web +Exec=%s %%U +Terminal=false +Type=Application +Categories=Network;WebBrowser; +MimeType=text/html;text/xml;application/xhtml+xml;application/xml;application/vnd.mozilla.xul+xml;application/rss+xml;application/rdf+xml;image/gif;image/jpeg;image/png;x-scheme-handler/http;x-scheme-handler/https;x-scheme-handler/ftp;x-scheme-handler/chrome;video/webm;application/x-xpinstall; +Icon=browseros +StartupWMClass=chromium-browser +`, execPath) + path := filepath.Join(appsDir, "browseros.desktop") + if err := os.WriteFile(path, []byte(desktopContent), 0o644); err != nil { + return "", err + } + return path, nil +} + +func copyLinuxIcon(iconSource, iconsDir string) error { + if !fileExists(iconSource) { + return nil + } + dest := filepath.Join(iconsDir, "256x256", "apps", "browseros.png") + if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { + return err + } + return copyFile(iconSource, dest) +} + +func createLinuxLauncher(binDir, appBinary string) error { + if err := os.MkdirAll(binDir, 0o755); err != nil { + return err + } + content := fmt.Sprintf(`#!/bin/sh +# BrowserOS launcher script +export LD_LIBRARY_PATH=/usr/lib/browseros:$LD_LIBRARY_PATH +exec /usr/lib/browseros/%s "$@" +`, appBinary) + path := filepath.Join(binDir, "browseros") + if err := os.WriteFile(path, []byte(content), 0o755); err != nil { + return err + } + return nil +} + +func createLinuxControlFile(ctx *Context, debianDir string) error { + if err := os.MkdirAll(debianDir, 0o755); err != nil { + return err + } + version := strings.TrimSpace(ctx.BrowserOSChromiumVersion) + version = strings.TrimPrefix(version, "v") + version = strings.ReplaceAll(version, " ", "") + version = strings.ReplaceAll(version, "_", ".") + if version == "" { + version = strings.TrimSpace(ctx.SemanticVersion) + } + if version == "" { + version = "0.0.0" + } + + debArch := "arm64" + if strings.TrimSpace(ctx.Architecture) == "x64" { + debArch = "amd64" + } + + content := fmt.Sprintf(`Package: browseros +Version: %s +Section: web +Priority: optional +Architecture: %s +Depends: libc6 (>= 2.31), libglib2.0-0, libnss3, libnspr4, libx11-6, libatk1.0-0, libatk-bridge2.0-0, libcups2, libasound2, libdrm2, libgbm1, libpango-1.0-0, libcairo2, libudev1, libxcomposite1, libxdamage1, libxrandr2, libxkbcommon0, libgtk-3-0 +Maintainer: BrowserOS Team +Homepage: https://www.browseros.com/ +Description: BrowserOS - The open source agentic browser + BrowserOS is a privacy-focused web browser built on Chromium, + designed for modern web browsing with AI capabilities. +`, version, debArch) + return os.WriteFile(filepath.Join(debianDir, "control"), []byte(content), 0o644) +} + +func createLinuxPostinstFile(debianDir string) error { + content := `#!/bin/sh +# Post-installation script for BrowserOS +set -e + +# Set SUID bit on chrome_sandbox for sandboxing support +if [ -f /usr/lib/browseros/chrome_sandbox ]; then + chmod 4755 /usr/lib/browseros/chrome_sandbox +fi + +exit 0 +` + path := filepath.Join(debianDir, "postinst") + if err := os.WriteFile(path, []byte(content), 0o755); err != nil { + return err + } + return nil +} diff --git a/packages/browseros/tools/bros/internal/native/build/package_macos.go b/packages/browseros/tools/bros/internal/native/build/package_macos.go new file mode 100644 index 00000000..9e658aa6 --- /dev/null +++ b/packages/browseros/tools/bros/internal/native/build/package_macos.go @@ -0,0 +1,163 @@ +package build + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" +) + +func runPackageMacOS(ctx *Context) error { + if runtime.GOOS != "darwin" { + return fmt.Errorf("DMG creation requires macOS") + } + + appPath := ctx.BrowserOSAppPath() + if !dirExists(appPath) { + return fmt.Errorf("app not found: %s", appPath) + } + + if err := os.MkdirAll(ctx.DistDir(), 0o755); err != nil { + return fmt.Errorf("creating dist dir: %w", err) + } + + dmgName, err := ctx.ArtifactName("dmg") + if err != nil { + return err + } + dmgPath := filepath.Join(ctx.DistDir(), dmgName) + pkgDmgPath := ctx.PkgDMGPath() + + if ctx.SignedApp { + if err := validateMacOSSigningEnv(ctx); err != nil { + return err + } + if err := createSignedNotarizedDMG(appPath, dmgPath, ctx.Env.MacOSCertificateName, pkgDmgPath, "notarytool-profile"); err != nil { + return err + } + return nil + } + + return createDMG(appPath, dmgPath, appBaseName, pkgDmgPath) +} + +func createSignedNotarizedDMG(appPath, dmgPath, certificate, pkgDmgPath, keychainProfile string) error { + if err := createDMG(appPath, dmgPath, appBaseName, pkgDmgPath); err != nil { + return err + } + if err := signDMG(dmgPath, certificate); err != nil { + return err + } + if err := notarizeDMG(dmgPath, keychainProfile); err != nil { + return err + } + return nil +} + +func createDMG(appPath, dmgPath, volumeName, pkgDmgPath string) error { + if !dirExists(appPath) { + return fmt.Errorf("app not found at %s", appPath) + } + + if err := os.MkdirAll(filepath.Dir(dmgPath), 0o755); err != nil { + return fmt.Errorf("creating dmg directory: %w", err) + } + if fileExists(dmgPath) { + if err := os.Remove(dmgPath); err != nil { + return fmt.Errorf("removing existing dmg: %w", err) + } + } + + cmd := []string{} + useChromiumPkgDMG := fileExists(pkgDmgPath) + if useChromiumPkgDMG { + cmd = append(cmd, pkgDmgPath) + } else { + systemPkgDMG, err := exec.LookPath("pkg-dmg") + if err != nil { + return fmt.Errorf("no pkg-dmg tool found") + } + cmd = append(cmd, systemPkgDMG) + } + + args := []string{ + "--sourcefile", "--source", appPath, + "--target", dmgPath, + "--volname", volumeName, + "--symlink", "/Applications:/Applications", + "--format", "UDBZ", + } + if useChromiumPkgDMG { + args = append(args, "--verbosity", "2") + } + + if err := runCmd("", cmd[0], args...); err != nil { + return fmt.Errorf("creating dmg: %w", err) + } + return nil +} + +func signDMG(dmgPath, certificate string) error { + if !fileExists(dmgPath) { + return fmt.Errorf("dmg not found at %s", dmgPath) + } + + if err := runCmd("", "codesign", "--sign", certificate, "--force", "--timestamp", dmgPath); err != nil { + return fmt.Errorf("signing dmg: %w", err) + } + + verifyRes, err := runCmdCapture("", "codesign", "-vvv", dmgPath) + if err != nil { + return err + } + if verifyRes.ExitCode != 0 { + return fmt.Errorf("dmg signature verification failed: %s", firstNonEmpty(verifyRes.Stderr, verifyRes.Stdout)) + } + + return nil +} + +func notarizeDMG(dmgPath, keychainProfile string) error { + if !fileExists(dmgPath) { + return fmt.Errorf("dmg not found at %s", dmgPath) + } + + submitRes, err := runCmdCapture("", "xcrun", "notarytool", "submit", dmgPath, "--keychain-profile", keychainProfile, "--wait") + if err != nil { + return err + } + if submitRes.ExitCode != 0 { + return fmt.Errorf("dmg notarization submission failed: %s", firstNonEmpty(submitRes.Stderr, submitRes.Stdout)) + } + if !strings.Contains(strings.ToLower(submitRes.Stdout), "status: accepted") { + return fmt.Errorf("dmg notarization failed: %s", submitRes.Stdout) + } + + stapleRes, err := runCmdCapture("", "xcrun", "stapler", "staple", dmgPath) + if err != nil { + return err + } + if stapleRes.ExitCode != 0 { + return fmt.Errorf("failed to staple dmg: %s", firstNonEmpty(stapleRes.Stderr, stapleRes.Stdout)) + } + + validateRes, err := runCmdCapture("", "xcrun", "stapler", "validate", dmgPath) + if err != nil { + return err + } + if validateRes.ExitCode != 0 { + return fmt.Errorf("dmg stapling verification failed: %s", firstNonEmpty(validateRes.Stderr, validateRes.Stdout)) + } + + spctlRes, err := runCmdCapture("", "spctl", "-a", "-vvv", "-t", "open", "--context", "context:primary-signature", dmgPath) + if err != nil { + return err + } + if spctlRes.ExitCode != 0 { + return fmt.Errorf("dmg security assessment failed: %s", firstNonEmpty(spctlRes.Stderr, spctlRes.Stdout)) + } + + return nil +} diff --git a/packages/browseros/tools/bros/internal/native/build/package_windows_impl.go b/packages/browseros/tools/bros/internal/native/build/package_windows_impl.go new file mode 100644 index 00000000..c1b0a156 --- /dev/null +++ b/packages/browseros/tools/bros/internal/native/build/package_windows_impl.go @@ -0,0 +1,79 @@ +package build + +import ( + "archive/zip" + "fmt" + "io" + "os" + "path/filepath" + "runtime" +) + +func runPackageWindows(ctx *Context) error { + if runtime.GOOS != "windows" { + return fmt.Errorf("Windows packaging requires Windows") + } + + buildOutputDir := filepath.Join(ctx.ChromiumSrc, ctx.OutDir) + miniInstaller := filepath.Join(buildOutputDir, "mini_installer.exe") + if !fileExists(miniInstaller) { + return fmt.Errorf("mini_installer.exe not found: %s", miniInstaller) + } + + outputDir := ctx.DistDir() + if err := os.MkdirAll(outputDir, 0o755); err != nil { + return fmt.Errorf("creating dist dir: %w", err) + } + + installerName, err := ctx.ArtifactName("installer") + if err != nil { + return err + } + installerPath := filepath.Join(outputDir, installerName) + if err := copyFile(miniInstaller, installerPath); err != nil { + return fmt.Errorf("creating installer: %w", err) + } + + zipName, err := ctx.ArtifactName("installer_zip") + if err != nil { + return err + } + zipPath := filepath.Join(outputDir, zipName) + if err := writeInstallerZip(zipPath, miniInstaller, installerName); err != nil { + return fmt.Errorf("creating installer zip: %w", err) + } + + return nil +} + +func writeInstallerZip(zipPath, sourceInstallerPath, zipEntryName string) error { + f, err := os.Create(zipPath) + if err != nil { + return err + } + defer f.Close() + + zw := zip.NewWriter(f) + entry, err := zw.Create(zipEntryName) + if err != nil { + _ = zw.Close() + return err + } + + src, err := os.Open(sourceInstallerPath) + if err != nil { + _ = zw.Close() + return err + } + defer src.Close() + + if _, err := io.Copy(entry, src); err != nil { + _ = zw.Close() + return err + } + + if err := zw.Close(); err != nil { + return err + } + return nil +} diff --git a/packages/browseros/tools/bros/internal/native/build/run.go b/packages/browseros/tools/bros/internal/native/build/run.go new file mode 100644 index 00000000..e3b4f543 --- /dev/null +++ b/packages/browseros/tools/bros/internal/native/build/run.go @@ -0,0 +1,366 @@ +package build + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + "slices" + "strings" + + "bros/internal/native/common" + + "gopkg.in/yaml.v3" +) + +type Options struct { + ConfigPath string + Modules string + ListModules bool + Setup bool + Prep bool + Build bool + Sign bool + Package bool + Upload bool + Arch string + BuildType string + ChromiumSrc string +} + +type yamlConfig struct { + Build struct { + ChromiumSrc string `yaml:"chromium_src"` + Architecture string `yaml:"architecture"` + Arch string `yaml:"arch"` + Type string `yaml:"type"` + } `yaml:"build"` + Modules []string `yaml:"modules"` + RequiredEnvs []string `yaml:"required_envs"` +} + +type ModuleDef struct { + Name string + Description string + Runner func(*Context) error +} + +var moduleDefs = []ModuleDef{ + {Name: "clean", Description: "Clean build artifacts and reset git state", Runner: runClean}, + {Name: "git_setup", Description: "Checkout Chromium version and sync dependencies", Runner: runGitSetup}, + {Name: "sparkle_setup", Description: "Download and setup Sparkle framework (macOS only)", Runner: runSparkleSetup}, + {Name: "configure", Description: "Configure build with GN", Runner: runConfigure}, + {Name: "patches", Description: "Apply BrowserOS patches to Chromium", Runner: runPatches}, + {Name: "series_patches", Description: "Apply series-based patches (GNU Quilt format)", Runner: runSeriesPatches}, + {Name: "chromium_replace", Description: "Replace Chromium source files with custom versions", Runner: runChromiumReplace}, + {Name: "string_replaces", Description: "Apply branding string replacements in Chromium", Runner: runStringReplaces}, + {Name: "download_resources", Description: "Download resources from Cloudflare R2", Runner: runDownloadResources}, + {Name: "resources", Description: "Copy resources (icons, binaries) into Chromium source", Runner: runResources}, + {Name: "bundled_extensions", Description: "Download and bundle extensions from CDN update manifest", Runner: runBundledExtensions}, + {Name: "compile", Description: "Build BrowserOS using autoninja", Runner: runCompile}, + {Name: "sign_macos", Description: "Sign and notarize macOS application", Runner: runSignMacOS}, + {Name: "sign_windows", Description: "Sign Windows binaries and installer", Runner: runSignWindows}, + {Name: "sign_linux", Description: "Linux code signing (no-op)", Runner: runSignLinux}, + {Name: "sparkle_sign", Description: "Sign DMGs with Sparkle Ed25519 key", Runner: runSparkleSign}, + {Name: "package_macos", Description: "Create macOS DMG package", Runner: runPackageMacOS}, + {Name: "package_windows", Description: "Create Windows installer and zip package", Runner: runPackageWindows}, + {Name: "package_linux", Description: "Create AppImage and .deb packages", Runner: runPackageLinux}, + {Name: "universal_build", Description: "Build, sign, package, upload universal binary (arm64 + x64)", Runner: runUniversalBuild}, + {Name: "upload", Description: "Upload build artifacts to Cloudflare R2", Runner: runUpload}, +} + +var moduleByName = func() map[string]ModuleDef { + out := make(map[string]ModuleDef, len(moduleDefs)) + for _, m := range moduleDefs { + out[m.Name] = m + } + return out +}() + +var executionOrder = []struct { + Phase string + Modules []string +}{ + {Phase: "setup", Modules: []string{"clean", "git_setup", "sparkle_setup"}}, + {Phase: "prep", Modules: []string{"download_resources", "resources", "bundled_extensions", "chromium_replace", "string_replaces", "patches", "configure"}}, + {Phase: "build", Modules: []string{"compile"}}, + {Phase: "sign", Modules: []string{signModuleForPlatform()}}, + {Phase: "package", Modules: []string{packageModuleForPlatform()}}, + {Phase: "upload", Modules: []string{"upload"}}, +} + +func Run(opts Options) error { + if opts.ListModules { + PrintModuleList() + return nil + } + + packagesDir, err := common.ResolveBrowserOSPackagesDir() + if err != nil { + return err + } + + configMode := strings.TrimSpace(opts.ConfigPath) != "" + modulesMode := strings.TrimSpace(opts.Modules) != "" + flagsMode := opts.Setup || opts.Prep || opts.Build || opts.Sign || opts.Package || opts.Upload + + providedModes := 0 + if configMode { + providedModes++ + } + if modulesMode { + providedModes++ + } + if flagsMode { + providedModes++ + } + + if providedModes == 0 { + return fmt.Errorf("specify --config, --modules, or phase flags (--setup, --build, etc.)") + } + if providedModes > 1 { + return fmt.Errorf("specify only one of: --config, --modules, or phase flags") + } + + var ( + ctx *Context + pipeline []string + requiredEnvs []string + ) + + if configMode { + if strings.TrimSpace(opts.Arch) != "" || strings.TrimSpace(opts.BuildType) != "" { + return fmt.Errorf("cannot use --arch or --build-type with --config (YAML is authoritative)") + } + yamlCfg, err := readYAMLConfig(opts.ConfigPath) + if err != nil { + return err + } + ctx, err = contextFromConfig(packagesDir, opts, yamlCfg) + if err != nil { + return err + } + if len(yamlCfg.Modules) == 0 { + return fmt.Errorf("config mode requires non-empty modules list") + } + pipeline = append([]string(nil), yamlCfg.Modules...) + requiredEnvs = append([]string(nil), yamlCfg.RequiredEnvs...) + } else { + ctx, err = contextFromDirect(packagesDir, opts) + if err != nil { + return err + } + if modulesMode { + pipeline = parseModules(opts.Modules) + if len(pipeline) == 0 { + return fmt.Errorf("--modules resolved to empty pipeline") + } + } else { + pipeline = pipelineFromFlags(opts) + if len(pipeline) == 0 { + return fmt.Errorf("no phase flags selected") + } + } + } + + if err := validateRequiredEnv(requiredEnvs); err != nil { + return err + } + + for _, moduleName := range pipeline { + module, ok := moduleByName[moduleName] + if !ok { + return fmt.Errorf("unknown module %q", moduleName) + } + if err := module.Runner(ctx); err != nil { + return fmt.Errorf("%s: %w", moduleName, err) + } + } + + return nil +} + +func PrintModuleList() { + fmt.Println() + fmt.Println("======================================================================") + fmt.Println("Available Build Modules") + fmt.Println("======================================================================") + fmt.Println() + for _, m := range moduleDefs { + fmt.Printf(" %-20s %s\n", m.Name, m.Description) + } + fmt.Println() +} + +func contextFromConfig(packagesDir string, opts Options, cfg *yamlConfig) (*Context, error) { + chromiumSrc := strings.TrimSpace(opts.ChromiumSrc) + if chromiumSrc == "" { + chromiumSrc = strings.TrimSpace(cfg.Build.ChromiumSrc) + } + if chromiumSrc == "" { + return nil, fmt.Errorf("config mode requires build.chromium_src or --chromium-src") + } + + arch := firstNonEmpty( + strings.TrimSpace(cfg.Build.Architecture), + strings.TrimSpace(cfg.Build.Arch), + defaultArch(), + ) + buildType := firstNonEmpty(strings.TrimSpace(cfg.Build.Type), "debug") + + absSrc, err := filepath.Abs(chromiumSrc) + if err != nil { + return nil, fmt.Errorf("resolving chromium source: %w", err) + } + if !dirExists(absSrc) { + return nil, fmt.Errorf("chromium source does not exist: %s", absSrc) + } + + return newContext(packagesDir, absSrc, arch, buildType) +} + +func contextFromDirect(packagesDir string, opts Options) (*Context, error) { + chromiumSrc := strings.TrimSpace(opts.ChromiumSrc) + if chromiumSrc == "" { + chromiumSrc = strings.TrimSpace(os.Getenv("CHROMIUM_SRC")) + } + if chromiumSrc == "" { + return nil, fmt.Errorf("direct mode requires --chromium-src or CHROMIUM_SRC") + } + + arch := strings.TrimSpace(opts.Arch) + if arch == "" { + arch = strings.TrimSpace(os.Getenv("ARCH")) + } + if arch == "" { + arch = defaultArch() + } + + buildType := strings.TrimSpace(opts.BuildType) + if buildType == "" { + buildType = "debug" + } + + absSrc, err := filepath.Abs(chromiumSrc) + if err != nil { + return nil, fmt.Errorf("resolving chromium source: %w", err) + } + if !dirExists(absSrc) { + return nil, fmt.Errorf("chromium source does not exist: %s", absSrc) + } + + return newContext(packagesDir, absSrc, arch, buildType) +} + +func readYAMLConfig(path string) (*yamlConfig, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading config %s: %w", path, err) + } + var cfg yamlConfig + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("parsing config %s: %w", path, err) + } + return &cfg, nil +} + +func validateRequiredEnv(required []string) error { + for _, key := range required { + key = strings.TrimSpace(key) + if key == "" { + continue + } + if strings.TrimSpace(os.Getenv(key)) == "" { + return fmt.Errorf("required environment variable %s is not set", key) + } + } + return nil +} + +func parseModules(modules string) []string { + parts := strings.Split(modules, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + if name := strings.TrimSpace(p); name != "" { + out = append(out, name) + } + } + return out +} + +func pipelineFromFlags(opts Options) []string { + var selected []string + for _, phase := range executionOrder { + enabled := false + switch phase.Phase { + case "setup": + enabled = opts.Setup + case "prep": + enabled = opts.Prep + case "build": + enabled = opts.Build + case "sign": + enabled = opts.Sign + case "package": + enabled = opts.Package + case "upload": + enabled = opts.Upload + } + if enabled { + selected = append(selected, phase.Modules...) + } + } + return selected +} + +func signModuleForPlatform() string { + switch runtime.GOOS { + case "darwin": + return "sign_macos" + case "windows": + return "sign_windows" + default: + return "sign_linux" + } +} + +func packageModuleForPlatform() string { + switch runtime.GOOS { + case "darwin": + return "package_macos" + case "windows": + return "package_windows" + default: + return "package_linux" + } +} + +func defaultArch() string { + switch runtime.GOARCH { + case "arm64": + return "arm64" + case "amd64": + return "x64" + default: + return "x64" + } +} + +func firstNonEmpty(values ...string) string { + for _, v := range values { + if strings.TrimSpace(v) != "" { + return strings.TrimSpace(v) + } + } + return "" +} + +func unsupportedModule(name string) func(*Context) error { + return func(ctx *Context) error { + return fmt.Errorf("module %q is not implemented yet in native Go migration", name) + } +} + +func containsString(slice []string, needle string) bool { + return slices.Contains(slice, needle) +} diff --git a/packages/browseros/tools/bros/internal/native/build/sign_macos.go b/packages/browseros/tools/bros/internal/native/build/sign_macos.go new file mode 100644 index 00000000..700591a8 --- /dev/null +++ b/packages/browseros/tools/bros/internal/native/build/sign_macos.go @@ -0,0 +1,554 @@ +package build + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + "sort" + "strings" +) + +type macBinarySignInfo struct { + IdentifierSuffix string + Options string + Entitlements string +} + +var browserOSServerSignInfo = map[string]macBinarySignInfo{ + "browseros_server": { + IdentifierSuffix: "browseros_server", + Options: "runtime", + Entitlements: "browseros-executable-entitlements.plist", + }, + "codex": { + IdentifierSuffix: "codex", + Options: "runtime", + Entitlements: "browseros-executable-entitlements.plist", + }, +} + +type macComponents struct { + Helpers []string + XPCServices []string + Frameworks []string + Dylibs []string + Executables []string + Apps []string +} + +func runSignMacOS(ctx *Context) error { + if runtime.GOOS != "darwin" { + return fmt.Errorf("macOS signing requires macOS") + } + + appPath := ctx.BrowserOSAppPath() + if !dirExists(appPath) { + return fmt.Errorf("app not found at: %s", appPath) + } + + if err := validateMacOSSigningEnv(ctx); err != nil { + return err + } + + unlockMacOSKeychain(ctx) + + if err := runCmd("", "xattr", "-cs", appPath); err != nil { + return fmt.Errorf("clearing extended attributes: %w", err) + } + + if err := signAllMacOSComponents(ctx, appPath, ctx.Env.MacOSCertificateName); err != nil { + return err + } + + if err := verifyMacOSSignature(appPath); err != nil { + return err + } + + if err := notarizeMacOSApp(ctx, appPath); err != nil { + return err + } + + ctx.SignedApp = true + return nil +} + +func validateMacOSSigningEnv(ctx *Context) error { + missing := make([]string, 0) + if strings.TrimSpace(ctx.Env.MacOSCertificateName) == "" { + missing = append(missing, "MACOS_CERTIFICATE_NAME") + } + if strings.TrimSpace(ctx.Env.MacOSNotarizationAppleID) == "" { + missing = append(missing, "PROD_MACOS_NOTARIZATION_APPLE_ID") + } + if strings.TrimSpace(ctx.Env.MacOSNotarizationTeamID) == "" { + missing = append(missing, "PROD_MACOS_NOTARIZATION_TEAM_ID") + } + if strings.TrimSpace(ctx.Env.MacOSNotarizationPassword) == "" { + missing = append(missing, "PROD_MACOS_NOTARIZATION_PWD") + } + if len(missing) > 0 { + return fmt.Errorf("missing environment variables: %s", strings.Join(missing, ", ")) + } + return nil +} + +func unlockMacOSKeychain(ctx *Context) { + password := strings.TrimSpace(ctx.Env.MacOSKeychainPassword) + if password == "" { + return + } + + keychainPath := filepath.Join(os.Getenv("HOME"), "Library", "Keychains", "login.keychain-db") + if !fileExists(keychainPath) { + return + } + + _, _ = runCmdCapture("", "security", "unlock-keychain", "-p", password, keychainPath) + _, _ = runCmdCapture("", "security", "set-keychain-settings", "-t", "3600", keychainPath) +} + +func signAllMacOSComponents(ctx *Context, appPath, certificate string) error { + components := discoverMacOSComponents(ctx, appPath) + + for _, xpc := range components.XPCServices { + if err := signMacOSComponent(certificate, xpc, macIdentifierForPath(xpc), macSigningOptionsForPath(xpc), ""); err != nil { + return err + } + } + + for _, nestedApp := range components.Apps { + if err := signMacOSComponent(certificate, nestedApp, macIdentifierForPath(nestedApp), macSigningOptionsForPath(nestedApp), ""); err != nil { + return err + } + } + + for _, exePath := range components.Executables { + entitlements := "" + if info, ok := browserOSServerBinaryInfo(exePath); ok { + entitlements = filepath.Join(ctx.EntitlementsDir(), info.Entitlements) + } + if err := signMacOSComponent(certificate, exePath, macIdentifierForPath(exePath), macSigningOptionsForPath(exePath), entitlements); err != nil { + return err + } + } + + for _, dylib := range components.Dylibs { + if err := signMacOSComponent(certificate, dylib, macIdentifierForPath(dylib), "", ""); err != nil { + return err + } + } + + for _, helper := range components.Helpers { + entitlements := "" + switch { + case strings.Contains(helper, "Renderer"): + entitlements = filepath.Join(ctx.EntitlementsDir(), "helper-renderer-entitlements.plist") + case strings.Contains(helper, "GPU"): + entitlements = filepath.Join(ctx.EntitlementsDir(), "helper-gpu-entitlements.plist") + case strings.Contains(helper, "Plugin"): + entitlements = filepath.Join(ctx.EntitlementsDir(), "helper-plugin-entitlements.plist") + } + if err := signMacOSComponent(certificate, helper, macIdentifierForPath(helper), macSigningOptionsForPath(helper), entitlements); err != nil { + return err + } + } + + frameworks := append([]string(nil), components.Frameworks...) + sort.Slice(frameworks, func(i, j int) bool { + iSparkle := strings.Contains(frameworks[i], "Sparkle") + jSparkle := strings.Contains(frameworks[j], "Sparkle") + if iSparkle == jSparkle { + return frameworks[i] < frameworks[j] + } + return iSparkle && !jSparkle + }) + for _, framework := range frameworks { + if err := signMacOSComponent(certificate, framework, macIdentifierForPath(framework), "", ""); err != nil { + return err + } + } + + mainExecutable, err := findMainMacExecutable(appPath) + if err != nil { + return err + } + if err := signMacOSComponent(certificate, mainExecutable, "com.browseros.BrowserOS", "", ""); err != nil { + return err + } + + requirements := `=designated => identifier "com.browseros.BrowserOS" and anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] /* exists */ and certificate leaf[field.1.2.840.113635.100.6.1.13] /* exists */` + appEntitlements := firstExistingPath([]string{ + filepath.Join(ctx.EntitlementsDir(), "app-entitlements.plist"), + filepath.Join(ctx.EntitlementsDir(), "app-entitlements-chrome.plist"), + filepath.Join(ctx.PackagesDir, "entitlements", "app-entitlements.plist"), + filepath.Join(ctx.PackagesDir, "entitlements", "app-entitlements-chrome.plist"), + filepath.Join(ctx.PackagesDir, "build", "src", "chrome", "app", "app-entitlements.plist"), + filepath.Join(ctx.PackagesDir, "build", "src", "chrome", "app", "app-entitlements-chrome.plist"), + filepath.Join(ctx.ChromiumSrc, "chrome", "app", "app-entitlements.plist"), + filepath.Join(ctx.ChromiumSrc, "chrome", "app", "app-entitlements-chrome.plist"), + }) + + cmd := []string{ + "--sign", certificate, + "--force", + "--timestamp", + "--identifier", "com.browseros.BrowserOS", + "--options", "restrict,library,runtime,kill", + "--requirements", requirements, + } + if appEntitlements != "" { + cmd = append(cmd, "--entitlements", appEntitlements) + } + cmd = append(cmd, appPath) + if err := runCmd("", "codesign", cmd...); err != nil { + return fmt.Errorf("signing app bundle: %w", err) + } + + return nil +} + +func signMacOSComponent(certificate, componentPath, identifier, options, entitlements string) error { + args := []string{"--sign", certificate, "--force", "--timestamp"} + if strings.TrimSpace(identifier) != "" { + args = append(args, "--identifier", identifier) + } + if strings.TrimSpace(options) != "" { + args = append(args, "--options", options) + } + if strings.TrimSpace(entitlements) != "" && fileExists(entitlements) { + args = append(args, "--entitlements", entitlements) + } + args = append(args, componentPath) + if err := runCmd("", "codesign", args...); err != nil { + return fmt.Errorf("signing %s: %w", componentPath, err) + } + return nil +} + +func verifyMacOSSignature(appPath string) error { + result, err := runCmdCapture("", "codesign", "--verify", "--deep", "--strict", "--verbose=2", appPath) + if err != nil { + return err + } + if result.ExitCode != 0 { + return fmt.Errorf("signature verification failed: %s", firstNonEmpty(result.Stderr, result.Stdout)) + } + return nil +} + +func notarizeMacOSApp(ctx *Context, appPath string) error { + zipPath := ctx.NotarizationZipPath() + _ = os.Remove(zipPath) + + if err := runCmd("", "ditto", "-c", "-k", "--keepParent", appPath, zipPath); err != nil { + return fmt.Errorf("creating notarization archive: %w", err) + } + + storeCredRes, err := runCmdCapture( + "", + "xcrun", "notarytool", "store-credentials", "notarytool-profile", + "--apple-id", ctx.Env.MacOSNotarizationAppleID, + "--team-id", ctx.Env.MacOSNotarizationTeamID, + "--password", ctx.Env.MacOSNotarizationPassword, + ) + if err != nil { + return err + } + + submitArgs := []string{"notarytool", "submit", zipPath, "--wait"} + if storeCredRes.ExitCode == 0 { + submitArgs = append(submitArgs, "--keychain-profile", "notarytool-profile") + } else { + submitArgs = append(submitArgs, + "--apple-id", ctx.Env.MacOSNotarizationAppleID, + "--team-id", ctx.Env.MacOSNotarizationTeamID, + "--password", ctx.Env.MacOSNotarizationPassword, + ) + } + + submitRes, err := runCmdCapture("", "xcrun", submitArgs...) + if err != nil { + return err + } + if submitRes.ExitCode != 0 { + return fmt.Errorf("notarization submit failed: %s", firstNonEmpty(submitRes.Stderr, submitRes.Stdout)) + } + if !strings.Contains(strings.ToLower(submitRes.Stdout), "status: accepted") { + return fmt.Errorf("notarization failed, output: %s", submitRes.Stdout) + } + + stapleRes, err := runCmdCapture("", "xcrun", "stapler", "staple", appPath) + if err != nil { + return err + } + if stapleRes.ExitCode != 0 { + return fmt.Errorf("failed to staple notarization ticket: %s", firstNonEmpty(stapleRes.Stderr, stapleRes.Stdout)) + } + + spctlRes, err := runCmdCapture("", "spctl", "-a", "-vvv", appPath) + if err != nil { + return err + } + if spctlRes.ExitCode != 0 { + return fmt.Errorf("gatekeeper check failed: %s", firstNonEmpty(spctlRes.Stderr, spctlRes.Stdout)) + } + + validateRes, err := runCmdCapture("", "xcrun", "stapler", "validate", appPath) + if err != nil { + return err + } + if validateRes.ExitCode != 0 { + return fmt.Errorf("stapler validation failed: %s", firstNonEmpty(validateRes.Stderr, validateRes.Stdout)) + } + + _ = os.Remove(zipPath) + return nil +} + +func discoverMacOSComponents(ctx *Context, appPath string) macComponents { + components := macComponents{} + frameworkPath := filepath.Join(appPath, "Contents", "Frameworks") + if !dirExists(frameworkPath) { + return components + } + + browserOSFrameworkRoots := make([]string, 0) + frameworkNames := []string{"BrowserOS Framework.framework", "BrowserOS Dev Framework.framework"} + for _, fwName := range frameworkNames { + fwPath := filepath.Join(frameworkPath, fwName) + if !dirExists(fwPath) { + continue + } + versioned := filepath.Join(fwPath, "Versions", ctx.BrowserOSChromiumVersion) + if strings.TrimSpace(ctx.BrowserOSChromiumVersion) != "" && dirExists(versioned) { + browserOSFrameworkRoots = append(browserOSFrameworkRoots, versioned) + } + browserOSFrameworkRoots = append(browserOSFrameworkRoots, fwPath) + } + + for _, fwRoot := range browserOSFrameworkRoots { + helpersDir := filepath.Join(fwRoot, "Helpers") + if !dirExists(helpersDir) { + continue + } + entries, err := os.ReadDir(helpersDir) + if err != nil { + continue + } + for _, entry := range entries { + fullPath := filepath.Join(helpersDir, entry.Name()) + if entry.IsDir() && strings.HasSuffix(entry.Name(), ".app") { + components.Helpers = append(components.Helpers, fullPath) + continue + } + if entry.Type().IsRegular() && filepath.Ext(entry.Name()) == "" && isExecutableFile(fullPath) { + components.Executables = append(components.Executables, fullPath) + } + } + break + } + + _ = filepath.WalkDir(frameworkPath, func(path string, d os.DirEntry, err error) error { + if err != nil { + return nil + } + if d.IsDir() { + if strings.HasSuffix(d.Name(), ".xpc") { + components.XPCServices = append(components.XPCServices, path) + } + if strings.HasSuffix(d.Name(), ".framework") { + components.Frameworks = append(components.Frameworks, path) + if strings.Contains(path, "Sparkle.framework") { + autoupdate := filepath.Join(path, "Versions", "B", "Autoupdate") + if fileExists(autoupdate) { + components.Executables = append(components.Executables, autoupdate) + } + } + } + if strings.HasSuffix(d.Name(), ".app") { + components.Apps = append(components.Apps, path) + } + return nil + } + if strings.HasSuffix(d.Name(), ".dylib") { + components.Dylibs = append(components.Dylibs, path) + } + return nil + }) + + for _, fwRoot := range browserOSFrameworkRoots { + librariesDir := filepath.Join(fwRoot, "Libraries") + entries, err := os.ReadDir(librariesDir) + if err != nil { + continue + } + for _, entry := range entries { + if entry.IsDir() || filepath.Ext(entry.Name()) != ".dylib" { + continue + } + components.Dylibs = append(components.Dylibs, filepath.Join(librariesDir, entry.Name())) + } + } + + serverDir := filepath.Join(appPath, "Contents", "Resources", "BrowserOSServer") + if dirExists(serverDir) { + _ = filepath.WalkDir(serverDir, func(path string, d os.DirEntry, err error) error { + if err != nil || d.IsDir() { + return nil + } + if filepath.Ext(d.Name()) == "" && isExecutableFile(path) { + components.Executables = append(components.Executables, path) + } + return nil + }) + } + + helperSet := toSet(components.Helpers) + filteredApps := make([]string, 0, len(components.Apps)) + for _, nestedApp := range components.Apps { + if _, ok := helperSet[nestedApp]; ok { + continue + } + filteredApps = append(filteredApps, nestedApp) + } + components.Apps = filteredApps + + components.Helpers = dedupeSorted(components.Helpers) + components.XPCServices = dedupeSorted(components.XPCServices) + components.Frameworks = dedupeSorted(components.Frameworks) + components.Dylibs = dedupeSorted(components.Dylibs) + components.Executables = dedupeSorted(components.Executables) + components.Apps = dedupeSorted(components.Apps) + return components +} + +func macIdentifierForPath(componentPath string) string { + baseIdentifier := "com.browseros" + name := strings.TrimSuffix(filepath.Base(componentPath), filepath.Ext(componentPath)) + + specialIdentifiers := map[string]string{ + "Downloader": "org.sparkle-project.Downloader", + "Installer": "org.sparkle-project.Installer", + "Updater": "org.sparkle-project.Updater", + "Autoupdate": "org.sparkle-project.Autoupdate", + "Sparkle": "org.sparkle-project.Sparkle", + "chrome_crashpad_handler": baseIdentifier + ".crashpad_handler", + "app_mode_loader": baseIdentifier + ".app_mode_loader", + "web_app_shortcut_copier": baseIdentifier + ".web_app_shortcut_copier", + } + for key, identifier := range specialIdentifiers { + if strings.Contains(componentPath, key) { + return identifier + } + } + + if info, ok := browserOSServerBinaryInfo(componentPath); ok { + return fmt.Sprintf("%s.%s", baseIdentifier, info.IdentifierSuffix) + } + + if strings.Contains(name, "Helper") { + start := strings.Index(name, "(") + end := strings.Index(name, ")") + if start >= 0 && end > start { + helperType := strings.ToLower(strings.TrimSpace(name[start+1 : end])) + return fmt.Sprintf("%s.helper.%s", baseIdentifier, helperType) + } + return baseIdentifier + ".helper" + } + + if filepath.Ext(componentPath) == ".framework" { + if name == "BrowserOS Framework" || name == "BrowserOS Dev Framework" { + return baseIdentifier + ".framework" + } + return fmt.Sprintf("%s.%s", baseIdentifier, strings.ToLower(strings.ReplaceAll(name, " ", "_"))) + } + + if filepath.Ext(componentPath) == ".dylib" { + return fmt.Sprintf("%s.%s", baseIdentifier, name) + } + + return fmt.Sprintf("%s.%s", baseIdentifier, strings.ToLower(strings.ReplaceAll(name, " ", "_"))) +} + +func macSigningOptionsForPath(componentPath string) string { + name := filepath.Base(componentPath) + lowerPath := strings.ToLower(componentPath) + + if strings.Contains(lowerPath, "sparkle") { + return "runtime" + } + if strings.Contains(name, "Helper (Renderer)") || strings.Contains(name, "Helper (GPU)") || strings.Contains(name, "Helper (Plugin)") { + return "restrict,kill,runtime" + } + if info, ok := browserOSServerBinaryInfo(componentPath); ok { + if strings.TrimSpace(info.Options) != "" { + return info.Options + } + } + if filepath.Ext(componentPath) == ".dylib" { + return "restrict,library,runtime,kill" + } + return "runtime" +} + +func browserOSServerBinaryInfo(componentPath string) (macBinarySignInfo, bool) { + name := strings.ToLower(strings.TrimSuffix(filepath.Base(componentPath), filepath.Ext(componentPath))) + info, ok := browserOSServerSignInfo[name] + return info, ok +} + +func findMainMacExecutable(appPath string) (string, error) { + candidates := []string{ + filepath.Join(appPath, "Contents", "MacOS", "BrowserOS"), + filepath.Join(appPath, "Contents", "MacOS", "BrowserOS Dev"), + } + for _, candidate := range candidates { + if fileExists(candidate) { + return candidate, nil + } + } + return "", fmt.Errorf("main executable not found in %s", filepath.Join(appPath, "Contents", "MacOS")) +} + +func firstExistingPath(candidates []string) string { + for _, candidate := range candidates { + if fileExists(candidate) { + return candidate + } + } + return "" +} + +func dedupeSorted(values []string) []string { + set := make(map[string]struct{}, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" { + continue + } + set[value] = struct{}{} + } + out := make([]string, 0, len(set)) + for value := range set { + out = append(out, value) + } + sort.Strings(out) + return out +} + +func toSet(values []string) map[string]struct{} { + set := make(map[string]struct{}, len(values)) + for _, value := range values { + set[value] = struct{}{} + } + return set +} + +func isExecutableFile(path string) bool { + info, err := os.Stat(path) + if err != nil { + return false + } + return !info.IsDir() && info.Mode()&0o111 != 0 +} diff --git a/packages/browseros/tools/bros/internal/native/build/sign_windows_impl.go b/packages/browseros/tools/bros/internal/native/build/sign_windows_impl.go new file mode 100644 index 00000000..0f13c97d --- /dev/null +++ b/packages/browseros/tools/bros/internal/native/build/sign_windows_impl.go @@ -0,0 +1,185 @@ +package build + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + "strings" +) + +var browserOSServerWindowsBinaries = []string{ + "browseros_server.exe", + "codex.exe", +} + +func runSignWindows(ctx *Context) error { + if runtime.GOOS != "windows" { + return fmt.Errorf("Windows signing requires Windows") + } + + buildOutputDir := filepath.Join(ctx.ChromiumSrc, ctx.OutDir) + if !dirExists(buildOutputDir) { + return fmt.Errorf("build output directory not found: %s", buildOutputDir) + } + + if strings.TrimSpace(ctx.Env.CodeSignToolPath) == "" && strings.TrimSpace(ctx.Env.CodeSignToolExe) == "" { + return fmt.Errorf("CODE_SIGN_TOOL_PATH or CODE_SIGN_TOOL_EXE environment variable not set") + } + + missing := make([]string, 0) + if strings.TrimSpace(ctx.Env.ESignerUsername) == "" { + missing = append(missing, "ESIGNER_USERNAME") + } + if strings.TrimSpace(ctx.Env.ESignerPassword) == "" { + missing = append(missing, "ESIGNER_PASSWORD") + } + if strings.TrimSpace(ctx.Env.ESignerTOTPSecret) == "" { + missing = append(missing, "ESIGNER_TOTP_SECRET") + } + if len(missing) > 0 { + return fmt.Errorf("missing environment variables: %s", strings.Join(missing, ", ")) + } + + binaries := []string{filepath.Join(buildOutputDir, "chrome.exe")} + for _, name := range browserOSServerWindowsBinaries { + binaries = append(binaries, filepath.Join(buildOutputDir, "BrowserOSServer", "default", "resources", "bin", name)) + } + + existing := make([]string, 0, len(binaries)) + for _, binary := range binaries { + if fileExists(binary) { + existing = append(existing, binary) + } + } + if len(existing) == 0 { + return fmt.Errorf("no binaries found to sign") + } + + if err := signWithCodeSignTool(existing, ctx); err != nil { + return err + } + + if err := buildMiniInstallerWindows(ctx); err != nil { + return err + } + + miniInstaller := filepath.Join(buildOutputDir, "mini_installer.exe") + if !fileExists(miniInstaller) { + return fmt.Errorf("mini_installer.exe not found: %s", miniInstaller) + } + + if err := signWithCodeSignTool([]string{miniInstaller}, ctx); err != nil { + return err + } + + ctx.SignedApp = true + return nil +} + +func buildMiniInstallerWindows(ctx *Context) error { + if err := runCmd(ctx.ChromiumSrc, "autoninja.bat", "-C", ctx.OutDir, "setup", "mini_installer"); err != nil { + return fmt.Errorf("building setup/mini_installer: %w", err) + } + + buildOutputDir := filepath.Join(ctx.ChromiumSrc, ctx.OutDir) + if !fileExists(filepath.Join(buildOutputDir, "setup.exe")) || !fileExists(filepath.Join(buildOutputDir, "mini_installer.exe")) { + return fmt.Errorf("build completed but setup.exe or mini_installer.exe is missing") + } + + return nil +} + +func signWithCodeSignTool(binaries []string, ctx *Context) error { + toolPath, err := resolveCodeSignToolPath(ctx) + if err != nil { + return err + } + + for _, binary := range binaries { + tempOutputDir := filepath.Join(filepath.Dir(binary), "signed_temp") + if err := os.MkdirAll(tempOutputDir, 0o755); err != nil { + return fmt.Errorf("creating temp output dir for %s: %w", binary, err) + } + + args := []string{ + "sign", + "-username", ctx.Env.ESignerUsername, + "-password", ctx.Env.ESignerPassword, + } + if strings.TrimSpace(ctx.Env.ESignerCredentialID) != "" { + args = append(args, "-credential_id", ctx.Env.ESignerCredentialID) + } + args = append(args, + "-totp_secret", ctx.Env.ESignerTOTPSecret, + "-input_file_path", binary, + "-output_dir_path", tempOutputDir, + "-override", + ) + + result, err := runCodeSignCommand(toolPath, filepath.Dir(toolPath), args...) + if err != nil { + return err + } + if result.ExitCode != 0 { + return fmt.Errorf("codesign tool failed for %s: %s", binary, firstNonEmpty(result.Stderr, result.Stdout)) + } + if strings.Contains(result.Stdout, "Error:") { + return fmt.Errorf("codesign tool reported an error for %s: %s", binary, strings.TrimSpace(result.Stdout)) + } + + signedFile := filepath.Join(tempOutputDir, filepath.Base(binary)) + if fileExists(signedFile) { + if err := os.Rename(signedFile, binary); err != nil { + return fmt.Errorf("moving signed file for %s: %w", binary, err) + } + } + _ = os.RemoveAll(tempOutputDir) + + if err := verifyWindowsSignature(binary); err != nil { + return err + } + } + + return nil +} + +func resolveCodeSignToolPath(ctx *Context) (string, error) { + if strings.TrimSpace(ctx.Env.CodeSignToolExe) != "" { + path := filepath.Clean(ctx.Env.CodeSignToolExe) + if !fileExists(path) { + return "", fmt.Errorf("CodeSignTool not found at %s", path) + } + return path, nil + } + + path := filepath.Join(ctx.Env.CodeSignToolPath, "CodeSignTool.bat") + if !fileExists(path) { + return "", fmt.Errorf("CodeSignTool not found at %s", path) + } + return path, nil +} + +func runCodeSignCommand(toolPath, cwd string, args ...string) (cmdResult, error) { + lower := strings.ToLower(toolPath) + if strings.HasSuffix(lower, ".bat") || strings.HasSuffix(lower, ".cmd") { + cmdArgs := append([]string{"/C", toolPath}, args...) + return runCmdCapture(cwd, "cmd", cmdArgs...) + } + return runCmdCapture(cwd, toolPath, args...) +} + +func verifyWindowsSignature(binaryPath string) error { + escaped := strings.ReplaceAll(binaryPath, "'", "''") + result, err := runCmdCapture("", "powershell", "-Command", fmt.Sprintf("(Get-AuthenticodeSignature '%s').Status", escaped)) + if err != nil { + return err + } + if result.ExitCode != 0 { + return fmt.Errorf("failed to verify signature for %s: %s", binaryPath, firstNonEmpty(result.Stderr, result.Stdout)) + } + if !strings.Contains(result.Stdout, "Valid") { + return fmt.Errorf("signature verification failed for %s: %s", binaryPath, strings.TrimSpace(result.Stdout)) + } + return nil +} diff --git a/packages/browseros/tools/bros/internal/native/build/sparkle_sign.go b/packages/browseros/tools/bros/internal/native/build/sparkle_sign.go new file mode 100644 index 00000000..ff8560a9 --- /dev/null +++ b/packages/browseros/tools/bros/internal/native/build/sparkle_sign.go @@ -0,0 +1,109 @@ +package build + +import ( + "crypto/ed25519" + "encoding/base64" + "fmt" + "os" + "path/filepath" + "strings" +) + +func runSparkleSign(ctx *Context) error { + if !ctx.Env.HasSparkleKey() { + return fmt.Errorf("SPARKLE_PRIVATE_KEY environment variable not set") + } + + distDir := ctx.DistDir() + if !dirExists(distDir) { + return nil + } + + entries, err := os.ReadDir(distDir) + if err != nil { + return fmt.Errorf("reading dist dir: %w", err) + } + + signed := 0 + for _, entry := range entries { + if entry.IsDir() { + continue + } + if strings.ToLower(filepath.Ext(entry.Name())) != ".dmg" { + continue + } + + fullPath := filepath.Join(distDir, entry.Name()) + sig, length, err := signSparkleFile(fullPath, ctx.Env.SparklePrivateKey) + if err != nil { + return fmt.Errorf("signing %s: %w", entry.Name(), err) + } + ctx.SparkleSignatures[entry.Name()] = SparkleSignature{ + Signature: sig, + Length: length, + } + signed++ + } + + if signed == 0 { + return nil + } + + return nil +} + +func signSparkleFile(filePath string, privateKeyValue string) (string, int64, error) { + privateKey, err := parseSparklePrivateKey(privateKeyValue) + if err != nil { + return "", 0, err + } + + fileData, err := os.ReadFile(filePath) + if err != nil { + return "", 0, fmt.Errorf("reading file: %w", err) + } + + signature := ed25519.Sign(privateKey, fileData) + return base64.StdEncoding.EncodeToString(signature), int64(len(fileData)), nil +} + +func parseSparklePrivateKey(keyData string) (ed25519.PrivateKey, error) { + keyData = strings.TrimSpace(keyData) + if keyData == "" { + return nil, fmt.Errorf("SPARKLE_PRIVATE_KEY is empty") + } + + keyBytes, decoded := decodePossiblyBase64(keyData) + if !decoded { + keyBytes = []byte(keyData) + } + + switch len(keyBytes) { + case ed25519.SeedSize: + return ed25519.NewKeyFromSeed(keyBytes), nil + case ed25519.PrivateKeySize: + return ed25519.NewKeyFromSeed(keyBytes[:ed25519.SeedSize]), nil + default: + return nil, fmt.Errorf("invalid Sparkle key length: %d bytes (expected 32 or 64)", len(keyBytes)) + } +} + +func decodePossiblyBase64(input string) ([]byte, bool) { + if decoded, err := base64.StdEncoding.DecodeString(input); err == nil { + return decoded, true + } + + if decoded, err := base64.RawStdEncoding.DecodeString(input); err == nil { + return decoded, true + } + + padded := input + if rem := len(input) % 4; rem != 0 { + padded = input + strings.Repeat("=", 4-rem) + if decoded, err := base64.StdEncoding.DecodeString(padded); err == nil { + return decoded, true + } + } + + return nil, false +} diff --git a/packages/browseros/tools/bros/internal/native/build/universal_build.go b/packages/browseros/tools/bros/internal/native/build/universal_build.go new file mode 100644 index 00000000..37c28a5b --- /dev/null +++ b/packages/browseros/tools/bros/internal/native/build/universal_build.go @@ -0,0 +1,315 @@ +package build + +import ( + "bytes" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "runtime" + "strings" +) + +var universalArchitectures = []string{"arm64", "x64"} + +func runUniversalBuild(ctx *Context) error { + if runtime.GOOS != "darwin" { + return fmt.Errorf("Universal builds only supported on macOS") + } + if err := validateMacOSSigningEnv(ctx); err != nil { + return err + } + + if err := cleanUniversalBuildDirectories(ctx); err != nil { + return err + } + + archApps := map[string]string{} + for _, arch := range universalArchitectures { + archCtx, err := newContext(ctx.PackagesDir, ctx.ChromiumSrc, arch, ctx.BuildType) + if err != nil { + return err + } + archCtx.FixedAppPath = filepath.Join(ctx.ChromiumSrc, "out", "Default_"+arch, "BrowserOS.app") + + if err := runResources(archCtx); err != nil { + return fmt.Errorf("%s resources: %w", arch, err) + } + if err := runConfigure(archCtx); err != nil { + return fmt.Errorf("%s configure: %w", arch, err) + } + if err := runCompile(archCtx); err != nil { + return fmt.Errorf("%s compile: %w", arch, err) + } + if !dirExists(archCtx.FixedAppPath) { + return fmt.Errorf("%s build failed, app not found: %s", arch, archCtx.FixedAppPath) + } + + if err := runSignMacOS(archCtx); err != nil { + return fmt.Errorf("%s sign: %w", arch, err) + } + if err := runPackageMacOS(archCtx); err != nil { + return fmt.Errorf("%s package: %w", arch, err) + } + _ = runUpload(archCtx) + + archApps[arch] = archCtx.FixedAppPath + } + + universalApp := filepath.Join(ctx.ChromiumSrc, "out", "Default_universal", "BrowserOS.app") + if err := mergeMacAppBundles(archApps["arm64"], archApps["x64"], universalApp); err != nil { + return err + } + + universalCtx, err := newContext(ctx.PackagesDir, ctx.ChromiumSrc, "universal", ctx.BuildType) + if err != nil { + return err + } + universalCtx.OutDir = filepath.Join("out", "Default_universal") + universalCtx.FixedAppPath = universalApp + + if err := runSignMacOS(universalCtx); err != nil { + return fmt.Errorf("universal sign: %w", err) + } + if err := runPackageMacOS(universalCtx); err != nil { + return fmt.Errorf("universal package: %w", err) + } + _ = runUpload(universalCtx) + + return nil +} + +func cleanUniversalBuildDirectories(ctx *Context) error { + for _, arch := range universalArchitectures { + path := filepath.Join(ctx.ChromiumSrc, "out", "Default_"+arch) + if dirExists(path) { + if err := os.RemoveAll(path); err != nil { + return fmt.Errorf("cleaning %s: %w", path, err) + } + } + } + universalPath := filepath.Join(ctx.ChromiumSrc, "out", "Default_universal") + if dirExists(universalPath) { + if err := os.RemoveAll(universalPath); err != nil { + return fmt.Errorf("cleaning %s: %w", universalPath, err) + } + } + return nil +} + +func mergeMacAppBundles(arm64App, x64App, outputApp string) error { + if !dirExists(arm64App) { + return fmt.Errorf("arm64 app not found: %s", arm64App) + } + if !dirExists(x64App) { + return fmt.Errorf("x64 app not found: %s", x64App) + } + + if dirExists(outputApp) { + if err := os.RemoveAll(outputApp); err != nil { + return fmt.Errorf("removing existing universal app: %w", err) + } + } + if err := os.MkdirAll(filepath.Dir(outputApp), 0o755); err != nil { + return fmt.Errorf("creating universal output dir: %w", err) + } + if err := runCmd("", "ditto", arm64App, outputApp); err != nil { + return fmt.Errorf("copying arm64 app bundle: %w", err) + } + + err := filepath.WalkDir(outputApp, func(outPath string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + rel, err := filepath.Rel(outputApp, outPath) + if err != nil { + return err + } + if rel == "." { + return nil + } + + x64Path := filepath.Join(x64App, rel) + x64Info, err := os.Lstat(x64Path) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + + outInfo, err := os.Lstat(outPath) + if err != nil { + return err + } + + if outInfo.IsDir() || x64Info.IsDir() { + return nil + } + + if outInfo.Mode()&os.ModeSymlink != 0 && x64Info.Mode()&os.ModeSymlink != 0 { + armTarget, err := os.Readlink(outPath) + if err != nil { + return err + } + x64Target, err := os.Readlink(x64Path) + if err != nil { + return err + } + if armTarget != x64Target { + return fmt.Errorf("symlink mismatch for %s: %s vs %s", rel, armTarget, x64Target) + } + return nil + } + + identical, err := filesEqual(outPath, x64Path) + if err != nil { + return err + } + if identical { + return nil + } + + baseName := filepath.Base(outPath) + if isInfoPlistPath(baseName) || baseName == "CodeResources" { + return nil + } + + isMachOArm := isMachOFile(outPath) + isMachOX64 := isMachOFile(x64Path) + if isMachOArm && isMachOX64 { + return createUniversalBinaryAtPath(outPath, x64Path) + } + + return fmt.Errorf("non-Mach-O files differ and cannot be merged: %s", rel) + }) + if err != nil { + return err + } + + return copyMissingFilesFromSecondTree(x64App, outputApp) +} + +func copyMissingFilesFromSecondTree(sourceRoot, destRoot string) error { + return filepath.WalkDir(sourceRoot, func(sourcePath string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + rel, err := filepath.Rel(sourceRoot, sourcePath) + if err != nil { + return err + } + if rel == "." { + return nil + } + destPath := filepath.Join(destRoot, rel) + if _, err := os.Lstat(destPath); err == nil { + return nil + } else if !os.IsNotExist(err) { + return err + } + + info, err := os.Lstat(sourcePath) + if err != nil { + return err + } + + if info.Mode()&os.ModeSymlink != 0 { + target, err := os.Readlink(sourcePath) + if err != nil { + return err + } + if err := os.Symlink(target, destPath); err != nil { + return err + } + return nil + } + + if d.IsDir() { + return os.MkdirAll(destPath, info.Mode().Perm()) + } + + if err := os.MkdirAll(filepath.Dir(destPath), 0o755); err != nil { + return err + } + return copyFile(sourcePath, destPath) + }) +} + +func createUniversalBinaryAtPath(armFile, x64File string) error { + temp := armFile + ".universal_tmp" + _ = os.Remove(temp) + if err := runCmd("", "lipo", "-create", "-output", temp, "-segalign", "x86_64", "0x4000", "-segalign", "arm64", "0x4000", armFile, x64File); err != nil { + return fmt.Errorf("lipo merge failed for %s: %w", armFile, err) + } + if err := os.Rename(temp, armFile); err != nil { + _ = os.Remove(temp) + return err + } + return nil +} + +func isMachOFile(path string) bool { + result, err := runCmdCapture("", "file", "-b", path) + if err != nil || result.ExitCode != 0 { + return false + } + return strings.Contains(result.Stdout, "Mach-O") +} + +func isInfoPlistPath(baseName string) bool { + if baseName == "Info.plist" { + return true + } + return strings.HasSuffix(baseName, "-Info.plist") +} + +func filesEqual(pathA, pathB string) (bool, error) { + infoA, err := os.Stat(pathA) + if err != nil { + return false, err + } + infoB, err := os.Stat(pathB) + if err != nil { + return false, err + } + if infoA.Size() != infoB.Size() { + return false, nil + } + + fA, err := os.Open(pathA) + if err != nil { + return false, err + } + defer fA.Close() + + fB, err := os.Open(pathB) + if err != nil { + return false, err + } + defer fB.Close() + + bufA := make([]byte, 64*1024) + bufB := make([]byte, 64*1024) + for { + nA, errA := fA.Read(bufA) + nB, errB := fB.Read(bufB) + if nA != nB { + return false, nil + } + if nA > 0 && !bytes.Equal(bufA[:nA], bufB[:nB]) { + return false, nil + } + + if errA == io.EOF && errB == io.EOF { + return true, nil + } + if errA != nil && errA != io.EOF { + return false, errA + } + if errB != nil && errB != io.EOF { + return false, errB + } + } +} diff --git a/packages/browseros/tools/bros/internal/native/common/env.go b/packages/browseros/tools/bros/internal/native/common/env.go new file mode 100644 index 00000000..64c9b053 --- /dev/null +++ b/packages/browseros/tools/bros/internal/native/common/env.go @@ -0,0 +1,148 @@ +package common + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "strings" +) + +// EnvConfig mirrors the subset of BrowserOS build env used by native OTA commands. +type EnvConfig struct { + MacOSCertificateName string + MacOSNotarizationAppleID string + MacOSNotarizationTeamID string + MacOSNotarizationPassword string + MacOSKeychainPassword string + CodeSignToolPath string + CodeSignToolExe string + ESignerUsername string + ESignerPassword string + ESignerTOTPSecret string + ESignerCredentialID string + R2AccountID string + R2AccessKeyID string + R2SecretAccessKey string + R2Bucket string + SparklePrivateKey string +} + +func LoadEnv(packagesDir string) EnvConfig { + loadDotEnv(packagesDir) + + bucket := strings.TrimSpace(os.Getenv("R2_BUCKET")) + if bucket == "" { + bucket = "browseros" + } + + return EnvConfig{ + MacOSCertificateName: strings.TrimSpace(os.Getenv("MACOS_CERTIFICATE_NAME")), + MacOSNotarizationAppleID: strings.TrimSpace(os.Getenv("PROD_MACOS_NOTARIZATION_APPLE_ID")), + MacOSNotarizationTeamID: strings.TrimSpace(os.Getenv("PROD_MACOS_NOTARIZATION_TEAM_ID")), + MacOSNotarizationPassword: strings.TrimSpace(os.Getenv("PROD_MACOS_NOTARIZATION_PWD")), + MacOSKeychainPassword: strings.TrimSpace(os.Getenv("MACOS_KEYCHAIN_PASSWORD")), + CodeSignToolPath: strings.TrimSpace(os.Getenv("CODE_SIGN_TOOL_PATH")), + CodeSignToolExe: strings.TrimSpace(os.Getenv("CODE_SIGN_TOOL_EXE")), + ESignerUsername: strings.TrimSpace(os.Getenv("ESIGNER_USERNAME")), + ESignerPassword: strings.TrimSpace(os.Getenv("ESIGNER_PASSWORD")), + ESignerTOTPSecret: strings.TrimSpace(os.Getenv("ESIGNER_TOTP_SECRET")), + ESignerCredentialID: strings.TrimSpace(os.Getenv("ESIGNER_CREDENTIAL_ID")), + R2AccountID: strings.TrimSpace(os.Getenv("R2_ACCOUNT_ID")), + R2AccessKeyID: strings.TrimSpace(os.Getenv("R2_ACCESS_KEY_ID")), + R2SecretAccessKey: strings.TrimSpace(os.Getenv("R2_SECRET_ACCESS_KEY")), + R2Bucket: bucket, + SparklePrivateKey: strings.TrimSpace(os.Getenv("SPARKLE_PRIVATE_KEY")), + } +} + +func (e EnvConfig) HasSparkleKey() bool { + return e.SparklePrivateKey != "" +} + +func (e EnvConfig) HasR2Config() bool { + return e.R2AccountID != "" && e.R2AccessKeyID != "" && e.R2SecretAccessKey != "" +} + +func (e EnvConfig) R2EndpointURL() string { + if e.R2AccountID == "" { + return "" + } + return fmt.Sprintf("https://%s.r2.cloudflarestorage.com", e.R2AccountID) +} + +func loadDotEnv(packagesDir string) { + if packagesDir == "" { + return + } + + repoRoot := filepath.Clean(filepath.Join(packagesDir, "..", "..")) + candidates := []string{ + filepath.Join(packagesDir, ".env"), + filepath.Join(repoRoot, ".env"), + } + + for _, candidate := range candidates { + if !isExistingRegularFile(candidate) { + continue + } + _ = loadDotEnvFile(candidate) + return + } +} + +func loadDotEnvFile(path string) error { + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + + s := bufio.NewScanner(f) + for s.Scan() { + line := strings.TrimSpace(s.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + if strings.HasPrefix(line, "export ") { + line = strings.TrimSpace(strings.TrimPrefix(line, "export ")) + } + + key, value, ok := strings.Cut(line, "=") + if !ok { + continue + } + + key = strings.TrimSpace(key) + if key == "" { + continue + } + + if _, exists := os.LookupEnv(key); exists { + continue + } + + value = strings.TrimSpace(value) + value = stripOuterQuotes(value) + _ = os.Setenv(key, value) + } + + return s.Err() +} + +func stripOuterQuotes(s string) string { + if len(s) < 2 { + return s + } + + if (s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '\'' && s[len(s)-1] == '\'') { + return s[1 : len(s)-1] + } + return s +} + +func isExistingRegularFile(path string) bool { + info, err := os.Stat(path) + return err == nil && info.Mode().IsRegular() +} diff --git a/packages/browseros/tools/bros/internal/native/common/paths.go b/packages/browseros/tools/bros/internal/native/common/paths.go new file mode 100644 index 00000000..6ee219cb --- /dev/null +++ b/packages/browseros/tools/bros/internal/native/common/paths.go @@ -0,0 +1,83 @@ +package common + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +const BrowserOSPackagesDirEnv = "BROWSEROS_PACKAGES_DIR" + +// ResolveBrowserOSPackagesDir finds packages/browseros for native build/release flows. +func ResolveBrowserOSPackagesDir() (string, error) { + if envPath := strings.TrimSpace(os.Getenv(BrowserOSPackagesDirEnv)); envPath != "" { + resolved, err := absolutePath(envPath) + if err != nil { + return "", err + } + if err := validatePackagesDir(resolved); err != nil { + return "", fmt.Errorf("%s=%q is invalid: %w", BrowserOSPackagesDirEnv, resolved, err) + } + return resolved, nil + } + + cwd, err := os.Getwd() + if err != nil { + return "", fmt.Errorf("getting cwd: %w", err) + } + + for dir := filepath.Clean(cwd); ; dir = filepath.Dir(dir) { + if err := validatePackagesDir(dir); err == nil { + return dir, nil + } + candidate := filepath.Join(dir, "packages", "browseros") + if err := validatePackagesDir(candidate); err == nil { + return candidate, nil + } + + parent := filepath.Dir(dir) + if parent == dir { + break + } + } + + return "", fmt.Errorf("could not locate packages/browseros (set %s)", BrowserOSPackagesDirEnv) +} + +func validatePackagesDir(dir string) error { + if dir == "" { + return fmt.Errorf("path is empty") + } + if !isRegularFile(filepath.Join(dir, "CHROMIUM_VERSION")) { + return fmt.Errorf("missing CHROMIUM_VERSION") + } + if !isDirectory(filepath.Join(dir, "chromium_patches")) { + return fmt.Errorf("missing chromium_patches/") + } + if !isDirectory(filepath.Join(dir, "build", "config")) { + return fmt.Errorf("missing build/config/") + } + return nil +} + +func absolutePath(p string) (string, error) { + if filepath.IsAbs(p) { + return filepath.Clean(p), nil + } + cwd, err := os.Getwd() + if err != nil { + return "", fmt.Errorf("getting cwd: %w", err) + } + return filepath.Clean(filepath.Join(cwd, p)), nil +} + +func isRegularFile(path string) bool { + info, err := os.Stat(path) + return err == nil && info.Mode().IsRegular() +} + +func isDirectory(path string) bool { + info, err := os.Stat(path) + return err == nil && info.IsDir() +} diff --git a/packages/browseros/tools/bros/internal/native/common/r2.go b/packages/browseros/tools/bros/internal/native/common/r2.go new file mode 100644 index 00000000..2f15b260 --- /dev/null +++ b/packages/browseros/tools/bros/internal/native/common/r2.go @@ -0,0 +1,66 @@ +package common + +import ( + "context" + "fmt" + "os" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/s3" +) + +type R2Client struct { + client *s3.Client + bucket string +} + +func NewR2Client(env EnvConfig) (*R2Client, error) { + if !env.HasR2Config() { + return nil, fmt.Errorf("R2 configuration not set") + } + + endpoint := env.R2EndpointURL() + if endpoint == "" { + return nil, fmt.Errorf("R2 endpoint could not be derived from R2_ACCOUNT_ID") + } + + cfg := aws.Config{ + Region: "auto", + Credentials: aws.NewCredentialsCache(credentials.NewStaticCredentialsProvider(env.R2AccessKeyID, env.R2SecretAccessKey, "")), + EndpointResolverWithOptions: aws.EndpointResolverWithOptionsFunc(func(service, region string, options ...interface{}) (aws.Endpoint, error) { + if service == s3.ServiceID { + return aws.Endpoint{URL: endpoint, HostnameImmutable: true}, nil + } + return aws.Endpoint{}, &aws.EndpointNotFoundError{} + }), + } + + client := s3.NewFromConfig(cfg, func(o *s3.Options) { + o.UsePathStyle = true + }) + + return &R2Client{ + client: client, + bucket: env.R2Bucket, + }, nil +} + +func (c *R2Client) UploadFile(ctx context.Context, localPath string, r2Key string) error { + f, err := os.Open(localPath) + if err != nil { + return fmt.Errorf("opening %s: %w", localPath, err) + } + defer f.Close() + + _, err = c.client.PutObject(ctx, &s3.PutObjectInput{ + Bucket: &c.bucket, + Key: aws.String(r2Key), + Body: f, + }) + if err != nil { + return fmt.Errorf("put object %s: %w", r2Key, err) + } + + return nil +} diff --git a/packages/browseros/tools/bros/internal/native/dev/annotate.go b/packages/browseros/tools/bros/internal/native/dev/annotate.go new file mode 100644 index 00000000..a80e1b78 --- /dev/null +++ b/packages/browseros/tools/bros/internal/native/dev/annotate.go @@ -0,0 +1,178 @@ +package dev + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" +) + +func runAnnotate(ctx *Context, featureFilter string) error { + fmt.Println("Annotate Features") + fmt.Println(strings.Repeat("=", 60)) + fmt.Printf("Chromium source: %s\n", ctx.ChromiumSrc) + fmt.Printf("Features file: %s\n", ctx.FeaturesFile()) + + commitsCreated, featuresSkipped, err := annotateFeatures(ctx, featureFilter) + if err != nil { + return err + } + + fmt.Println() + fmt.Println(strings.Repeat("=", 60)) + if commitsCreated > 0 { + fmt.Printf("Created %d commit(s)\n", commitsCreated) + } else { + fmt.Println("No commits created (no modified files found)") + } + if featuresSkipped > 0 { + fmt.Printf("Skipped %d feature(s) with no changes\n", featuresSkipped) + } + fmt.Println(strings.Repeat("=", 60)) + return nil +} + +func annotateFeatures(ctx *Context, featureFilter string) (int, int, error) { + features, exists, err := loadFeaturesFile(ctx.FeaturesFile()) + if err != nil { + return 0, 0, err + } + if !exists { + fmt.Printf("Features file not found: %s\n", ctx.FeaturesFile()) + return 0, 0, nil + } + if len(features.Features) == 0 { + fmt.Println("No features found in features.yaml") + return 0, 0, nil + } + + selected := make(map[string]*featureEntry) + if strings.TrimSpace(featureFilter) != "" { + feature, ok := features.Features[featureFilter] + if !ok { + fmt.Printf("Feature %q not found in features.yaml\n", featureFilter) + return 0, 0, nil + } + selected[featureFilter] = feature + } else { + for name, feature := range features.Features { + selected[name] = feature + } + } + + fmt.Printf("Processing %d feature(s)\n", len(selected)) + fmt.Println(strings.Repeat("=", 60)) + + commitsCreated := 0 + featuresSkipped := 0 + + for _, featureName := range sortedFeatureNames(selected) { + feature := selected[featureName] + if feature == nil { + feature = &featureEntry{} + } + description := strings.TrimSpace(feature.Description) + if description == "" { + description = featureName + } + files := normalizePaths(feature.Files) + + fmt.Println() + fmt.Printf("%s\n", featureName) + fmt.Printf(" %s\n", description) + + if len(files) == 0 { + fmt.Println(" No files specified, skipping") + featuresSkipped++ + continue + } + + modifiedFiles, err := modifiedFilesForFeature(ctx.ChromiumSrc, files) + if err != nil { + fmt.Printf(" Failed to inspect files: %v\n", err) + featuresSkipped++ + continue + } + if len(modifiedFiles) == 0 { + fmt.Printf(" No modified files (%d files checked)\n", len(files)) + featuresSkipped++ + continue + } + + fmt.Printf(" Found %d modified file(s)\n", len(modifiedFiles)) + committed, err := gitAddAndCommit(ctx.ChromiumSrc, modifiedFiles, description) + if err != nil { + fmt.Printf(" Failed to commit: %v\n", err) + featuresSkipped++ + continue + } + if !committed { + fmt.Println(" No changes staged, skipping commit") + featuresSkipped++ + continue + } + + fmt.Printf(" Committed %d file(s)\n", len(modifiedFiles)) + commitsCreated++ + } + + return commitsCreated, featuresSkipped, nil +} + +func modifiedFilesForFeature(chromiumSrc string, files []string) ([]string, error) { + modified := make([]string, 0) + seen := map[string]struct{}{} + + for _, filePath := range files { + if _, ok := seen[filePath]; ok { + continue + } + seen[filePath] = struct{}{} + + fullPath := filepath.Join(chromiumSrc, filepath.FromSlash(filePath)) + if _, err := os.Stat(fullPath); err != nil { + continue + } + + result, err := runGit(chromiumSrc, "status", "--porcelain", "--", filePath) + if err != nil { + return nil, err + } + if result.ExitCode == 0 && strings.TrimSpace(result.Stdout) != "" { + modified = append(modified, filePath) + } + } + + return modified, nil +} + +func gitAddAndCommit(chromiumSrc string, files []string, commitMessage string) (bool, error) { + for _, filePath := range files { + result, err := runGit(chromiumSrc, "add", "--", filePath) + if err != nil { + return false, err + } + if result.ExitCode != 0 { + return false, fmt.Errorf("failed to add file %q: %s", filePath, strings.TrimSpace(result.Stderr)) + } + } + + commitResult, err := runGit(chromiumSrc, "commit", "-m", commitMessage) + if err != nil { + return false, err + } + if commitResult.ExitCode != 0 { + combined := strings.ToLower(strings.TrimSpace(commitResult.Stderr + "\n" + commitResult.Stdout)) + if strings.Contains(combined, "nothing to commit") || strings.Contains(combined, "nothing added to commit") { + return false, nil + } + msg := strings.TrimSpace(commitResult.Stderr) + if msg == "" { + msg = "git commit failed" + } + return false, errors.New(msg) + } + + return true, nil +} diff --git a/packages/browseros/tools/bros/internal/native/dev/classify.go b/packages/browseros/tools/bros/internal/native/dev/classify.go new file mode 100644 index 00000000..ad368dae --- /dev/null +++ b/packages/browseros/tools/bros/internal/native/dev/classify.go @@ -0,0 +1,310 @@ +package dev + +import ( + "bufio" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "sort" + "strconv" + "strings" +) + +type featureSelection struct { + Name string + Description string +} + +func runFeatureClassify(ctx *Context) error { + if !isDirectory(ctx.PatchesDir()) { + return fmt.Errorf("patches directory not found: %s", ctx.PatchesDir()) + } + + unclassified, err := getUnclassifiedFiles(ctx) + if err != nil { + return err + } + if len(unclassified) == 0 { + fmt.Println("All patch files are already classified!") + return nil + } + + fmt.Printf("Found %d unclassified patch file(s)\n", len(unclassified)) + fmt.Println() + + classified, skipped, err := classifyFilesInteractive(ctx, unclassified) + if err != nil { + return err + } + + fmt.Println() + fmt.Println(strings.Repeat("=", 60)) + fmt.Printf("Classified %d file(s)\n", classified) + if skipped > 0 { + fmt.Printf("Skipped %d file(s)\n", skipped) + } + remaining := len(unclassified) - classified - skipped + if remaining > 0 { + fmt.Printf("Remaining: %d file(s)\n", remaining) + } + return nil +} + +func classifyFilesInteractive(ctx *Context, unclassified []string) (int, int, error) { + fmt.Println(strings.Repeat("=", 60)) + fmt.Println("Press Ctrl+C to stop at any time") + fmt.Println() + + classifiedCount := 0 + skippedCount := 0 + reader := bufio.NewReader(os.Stdin) + + for i, filePath := range unclassified { + fmt.Printf("\n[%d/%d] %s\n", i+1, len(unclassified), filePath) + fmt.Println(strings.Repeat("-", 40)) + + selection, err := promptFeatureSelectionForFile(ctx, reader, filePath) + if err != nil { + return classifiedCount, skippedCount, err + } + if selection == nil { + fmt.Println("Skipped") + skippedCount++ + continue + } + + if _, err := addFilesToFeature(ctx, selection.Name, selection.Description, []string{filePath}); err != nil { + return classifiedCount, skippedCount, err + } + classifiedCount++ + } + + return classifiedCount, skippedCount, nil +} + +func promptFeatureSelectionForFile(ctx *Context, reader *bufio.Reader, filePath string) (*featureSelection, error) { + features, _, err := loadFeaturesFile(ctx.FeaturesFile()) + if err != nil { + return nil, err + } + + if len(features.Features) == 0 { + fmt.Println("No features defined yet. Create a new one:") + return promptNewFeature(reader, "") + } + + names := sortedFeatureNames(features.Features) + for i, name := range names { + feature := features.Features[name] + if feature == nil { + feature = &featureEntry{} + } + desc := feature.Description + if strings.TrimSpace(desc) == "" { + desc = name + } + fmt.Printf(" %d) %s (%d files)\n", i+1, desc, len(feature.Files)) + } + + newOption := len(names) + 1 + skipOption := len(names) + 2 + fmt.Printf(" %d) [Add new feature]\n", newOption) + fmt.Printf(" %d) [Skip this file]\n", skipOption) + + for { + choice, err := readLine(reader, fmt.Sprintf("Choice (1-%d): ", skipOption)) + if err != nil { + if errors.Is(err, io.EOF) { + return nil, nil + } + return nil, err + } + if strings.TrimSpace(choice) == "" { + return nil, nil + } + + choiceNum, err := strconv.Atoi(choice) + if err != nil { + fmt.Println("Enter a valid number") + continue + } + if choiceNum < 1 || choiceNum > skipOption { + fmt.Printf("Please enter 1-%d\n", skipOption) + continue + } + + switch choiceNum { + case skipOption: + return nil, nil + case newOption: + return promptNewFeature(reader, "") + default: + name := names[choiceNum-1] + feature := features.Features[name] + desc := "" + if feature != nil { + desc = feature.Description + } + return &featureSelection{Name: name, Description: desc}, nil + } + } +} + +func promptNewFeature(reader *bufio.Reader, defaultDescription string) (*featureSelection, error) { + fmt.Println() + fmt.Println("Creating new feature:") + fmt.Println(strings.Repeat("-", 40)) + fmt.Printf(" Valid prefixes: %s\n", strings.Join(validDescriptionPrefixes, ", ")) + fmt.Println() + + for { + featureName, err := readLine(reader, "Feature name (kebab-case): ") + if err != nil { + if errors.Is(err, io.EOF) { + fmt.Println("Cancelled") + return nil, nil + } + return nil, err + } + if strings.TrimSpace(featureName) == "" { + fmt.Println("Cancelled - no feature name provided") + return nil, nil + } + + sanitized := strings.ToLower(strings.ReplaceAll(strings.TrimSpace(featureName), " ", "-")) + if err := validateFeatureName(sanitized); err != nil { + fmt.Printf("Invalid name: %s\n", err) + continue + } + + for { + descPrompt := "Description (e.g., feat: Add feature): " + if strings.TrimSpace(defaultDescription) != "" { + if err := validateDescription(defaultDescription); err == nil { + descPrompt = fmt.Sprintf("Description [%s]: ", defaultDescription) + } else { + descPrompt = fmt.Sprintf("Description (e.g., feat: %s): ", defaultDescription) + } + } + + description, err := readLine(reader, descPrompt) + if err != nil { + if errors.Is(err, io.EOF) { + fmt.Println("Cancelled") + return nil, nil + } + return nil, err + } + + description = strings.TrimSpace(description) + if description == "" && strings.TrimSpace(defaultDescription) != "" { + if err := validateDescription(defaultDescription); err == nil { + description = defaultDescription + } else { + fmt.Printf("Default description needs prefix. Valid: %s\n", strings.Join(validDescriptionPrefixes, ", ")) + continue + } + } + if description == "" { + fmt.Printf("Description required. Must start with: %s\n", strings.Join(validDescriptionPrefixes, ", ")) + continue + } + if err := validateDescription(description); err != nil { + fmt.Printf("Invalid description: %s\n", err) + continue + } + + return &featureSelection{ + Name: sanitized, + Description: description, + }, nil + } + } +} + +func getUnclassifiedFiles(ctx *Context) ([]string, error) { + allPatchFiles, err := getAllPatchFiles(ctx) + if err != nil { + return nil, err + } + + classified, err := getAllClassifiedFiles(ctx) + if err != nil { + return nil, err + } + + unclassified := make([]string, 0) + for _, file := range allPatchFiles { + if _, ok := classified[file]; !ok { + unclassified = append(unclassified, file) + } + } + sort.Strings(unclassified) + return unclassified, nil +} + +func getAllPatchFiles(ctx *Context) ([]string, error) { + root := ctx.PatchesDir() + if !isDirectory(root) { + return []string{}, nil + } + + patchFiles := make([]string, 0) + err := filepath.WalkDir(root, func(path string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if d.IsDir() { + return nil + } + relPath, err := filepath.Rel(root, path) + if err != nil { + return err + } + patchFiles = append(patchFiles, filepath.ToSlash(relPath)) + return nil + }) + if err != nil { + return nil, fmt.Errorf("scanning patches directory: %w", err) + } + + sort.Strings(patchFiles) + return patchFiles, nil +} + +func getAllClassifiedFiles(ctx *Context) (map[string]struct{}, error) { + features, _, err := loadFeaturesFile(ctx.FeaturesFile()) + if err != nil { + return nil, err + } + + classified := make(map[string]struct{}) + for _, feature := range features.Features { + if feature == nil { + continue + } + for _, file := range normalizePaths(feature.Files) { + classified[file] = struct{}{} + } + } + return classified, nil +} + +func readLine(reader *bufio.Reader, prompt string) (string, error) { + fmt.Print(prompt) + line, err := reader.ReadString('\n') + if err != nil { + if errors.Is(err, io.EOF) { + line = strings.TrimSpace(line) + if line == "" { + return "", io.EOF + } + return line, nil + } + return "", err + } + return strings.TrimSpace(line), nil +} diff --git a/packages/browseros/tools/bros/internal/native/dev/context.go b/packages/browseros/tools/bros/internal/native/dev/context.go new file mode 100644 index 00000000..2beda2ba --- /dev/null +++ b/packages/browseros/tools/bros/internal/native/dev/context.go @@ -0,0 +1,65 @@ +package dev + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + nativecommon "bros/internal/native/common" +) + +// Context contains resolved paths used by native dev commands. +type Context struct { + PackagesDir string + ChromiumSrc string + Verbose bool + Quiet bool +} + +func newContext(opts Options) (*Context, error) { + chromiumSrc := strings.TrimSpace(opts.ChromiumSrc) + if chromiumSrc == "" { + return nil, fmt.Errorf("Chromium source directory not specified (use --chromium-src)") + } + + absChromiumSrc, err := filepath.Abs(chromiumSrc) + if err != nil { + return nil, fmt.Errorf("resolving chromium source: %w", err) + } + info, err := os.Stat(absChromiumSrc) + if err != nil || !info.IsDir() { + return nil, fmt.Errorf("Chromium source directory does not exist: %s", absChromiumSrc) + } + + packagesDir, err := nativecommon.ResolveBrowserOSPackagesDir() + if err != nil { + return nil, err + } + _ = nativecommon.LoadEnv(packagesDir) + + return &Context{ + PackagesDir: packagesDir, + ChromiumSrc: absChromiumSrc, + Verbose: opts.Verbose, + Quiet: opts.Quiet, + }, nil +} + +func (c *Context) PatchesDir() string { + return filepath.Join(c.PackagesDir, "chromium_patches") +} + +func (c *Context) FeaturesFile() string { + return filepath.Join(c.PackagesDir, "build", "features.yaml") +} + +func isRegularFile(path string) bool { + info, err := os.Stat(path) + return err == nil && info.Mode().IsRegular() +} + +func isDirectory(path string) bool { + info, err := os.Stat(path) + return err == nil && info.IsDir() +} diff --git a/packages/browseros/tools/bros/internal/native/dev/feature_ops.go b/packages/browseros/tools/bros/internal/native/dev/feature_ops.go new file mode 100644 index 00000000..460fe956 --- /dev/null +++ b/packages/browseros/tools/bros/internal/native/dev/feature_ops.go @@ -0,0 +1,258 @@ +package dev + +import ( + "fmt" + "sort" + "strings" +) + +func runFeatureList(ctx *Context) error { + features, exists, err := loadFeaturesFile(ctx.FeaturesFile()) + if err != nil { + return err + } + if !exists { + fmt.Println("No features.yaml found") + return nil + } + if len(features.Features) == 0 { + fmt.Println("No features defined") + return nil + } + + fmt.Printf("Features (%d):\n", len(features.Features)) + fmt.Println(strings.Repeat("-", 60)) + + for _, name := range sortedFeatureNames(features.Features) { + config := features.Features[name] + if config == nil { + config = &featureEntry{} + } + fmt.Printf(" %s: %d files - %s\n", name, len(config.Files), config.Description) + } + return nil +} + +func runFeatureShow(ctx *Context, featureName string) error { + features, exists, err := loadFeaturesFile(ctx.FeaturesFile()) + if err != nil { + return err + } + if !exists { + fmt.Println("No features.yaml found") + return nil + } + if len(features.Features) == 0 { + fmt.Println("No features defined") + return nil + } + + feature, ok := features.Features[featureName] + if !ok || feature == nil { + fmt.Printf("Feature %q not found\n", featureName) + fmt.Println("Available features:") + for _, name := range sortedFeatureNames(features.Features) { + fmt.Printf(" - %s\n", name) + } + return nil + } + + commit := strings.TrimSpace(feature.Commit) + if commit == "" { + commit = "Unknown" + } + + fmt.Printf("Feature: %s\n", featureName) + fmt.Println(strings.Repeat("-", 60)) + fmt.Printf("Description: %s\n", feature.Description) + fmt.Printf("Commit: %s\n", commit) + fmt.Printf("Files (%d):\n", len(feature.Files)) + for _, filePath := range feature.Files { + fmt.Printf(" - %s\n", filePath) + } + return nil +} + +func runFeatureAddUpdate(ctx *Context, flags addUpdateFlags) error { + if err := validateFeatureName(flags.Name); err != nil { + return err + } + if err := validateDescription(flags.Description); err != nil { + return err + } + + changedFiles, err := commitChangedFiles(ctx.ChromiumSrc, flags.Commit) + if err != nil { + return err + } + if len(changedFiles) == 0 { + return fmt.Errorf("No changed files found in commit %s", flags.Commit) + } + + features, _, err := loadFeaturesFile(ctx.FeaturesFile()) + if err != nil { + return err + } + + existingFeature, exists := features.Features[flags.Name] + if exists && existingFeature == nil { + existingFeature = &featureEntry{} + } + + if exists { + existingFilesSet := toSet(existingFeature.Files) + newFilesSet := toSet(changedFiles) + addedFiles := setDifference(newFilesSet, existingFilesSet) + alreadyPresent := setIntersection(newFilesSet, existingFilesSet) + mergedFiles := setUnion(existingFilesSet, newFilesSet) + + fmt.Printf("Updating existing feature %q\n", flags.Name) + fmt.Printf(" Current files: %d\n", len(existingFilesSet)) + fmt.Printf(" Files from commit: %d\n", len(newFilesSet)) + + if len(addedFiles) > 0 { + fmt.Printf(" Adding %d new file(s):\n", len(addedFiles)) + limit := min(10, len(addedFiles)) + for i := 0; i < limit; i++ { + fmt.Printf(" + %s\n", addedFiles[i]) + } + if len(addedFiles) > 10 { + fmt.Printf(" ... and %d more\n", len(addedFiles)-10) + } + } + if len(alreadyPresent) > 0 { + fmt.Printf(" Skipping %d file(s) already in feature\n", len(alreadyPresent)) + } + + existingFeature.Files = mergedFiles + existingFeature.Description = flags.Description + features.Features[flags.Name] = existingFeature + } else { + fmt.Printf("Creating new feature %q\n", flags.Name) + fmt.Printf(" Files from commit: %d\n", len(changedFiles)) + features.Features[flags.Name] = &featureEntry{ + Description: flags.Description, + Files: normalizePaths(changedFiles), + } + } + + if err := saveFeaturesFile(ctx.FeaturesFile(), features); err != nil { + return err + } + + totalFiles := len(features.Features[flags.Name].Files) + if exists { + fmt.Printf("Updated feature %q - now has %d files\n", flags.Name, totalFiles) + } else { + fmt.Printf("Created feature %q with %d files\n", flags.Name, totalFiles) + } + return nil +} + +func addFilesToFeature(ctx *Context, featureName, description string, files []string) (int, error) { + features, _, err := loadFeaturesFile(ctx.FeaturesFile()) + if err != nil { + return 0, err + } + + feature, exists := features.Features[featureName] + if !exists || feature == nil { + feature = &featureEntry{ + Description: strings.TrimSpace(description), + Files: []string{}, + } + features.Features[featureName] = feature + } else if strings.TrimSpace(feature.Description) == "" { + feature.Description = strings.TrimSpace(description) + } + + existing := toSet(feature.Files) + newFiles := make([]string, 0, len(files)) + duplicates := make([]string, 0) + for _, filePath := range normalizePaths(files) { + if _, ok := existing[filePath]; ok { + duplicates = append(duplicates, filePath) + continue + } + existing[filePath] = struct{}{} + newFiles = append(newFiles, filePath) + } + + feature.Files = sortedKeys(existing) + if err := saveFeaturesFile(ctx.FeaturesFile(), features); err != nil { + return 0, err + } + + if len(newFiles) > 0 { + fmt.Printf("Added %d file(s) to feature %q\n", len(newFiles), featureName) + limit := min(5, len(newFiles)) + for i := 0; i < limit; i++ { + fmt.Printf(" + %s\n", newFiles[i]) + } + if len(newFiles) > 5 { + fmt.Printf(" ... and %d more\n", len(newFiles)-5) + } + } + if len(duplicates) > 0 { + fmt.Printf("Skipped %d duplicate file(s)\n", len(duplicates)) + limit := min(3, len(duplicates)) + for i := 0; i < limit; i++ { + fmt.Printf(" ~ %s\n", duplicates[i]) + } + if len(duplicates) > 3 { + fmt.Printf(" ... and %d more\n", len(duplicates)-3) + } + } + + return len(newFiles), nil +} + +func toSet(files []string) map[string]struct{} { + set := make(map[string]struct{}, len(files)) + for _, file := range normalizePaths(files) { + set[file] = struct{}{} + } + return set +} + +func sortedKeys(set map[string]struct{}) []string { + out := make([]string, 0, len(set)) + for k := range set { + out = append(out, k) + } + sort.Strings(out) + return out +} + +func setDifference(a, b map[string]struct{}) []string { + out := make([]string, 0) + for k := range a { + if _, ok := b[k]; !ok { + out = append(out, k) + } + } + sort.Strings(out) + return out +} + +func setIntersection(a, b map[string]struct{}) []string { + out := make([]string, 0) + for k := range a { + if _, ok := b[k]; ok { + out = append(out, k) + } + } + sort.Strings(out) + return out +} + +func setUnion(a, b map[string]struct{}) []string { + union := make(map[string]struct{}, len(a)+len(b)) + for k := range a { + union[k] = struct{}{} + } + for k := range b { + union[k] = struct{}{} + } + return sortedKeys(union) +} diff --git a/packages/browseros/tools/bros/internal/native/dev/features_yaml.go b/packages/browseros/tools/bros/internal/native/dev/features_yaml.go new file mode 100644 index 00000000..85bd5869 --- /dev/null +++ b/packages/browseros/tools/bros/internal/native/dev/features_yaml.go @@ -0,0 +1,172 @@ +package dev + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "gopkg.in/yaml.v3" +) + +type featureEntry struct { + Description string + Files []string + Commit string +} + +type featuresFile struct { + Version string + Features map[string]*featureEntry +} + +func loadFeaturesFile(path string) (*featuresFile, bool, error) { + if !isRegularFile(path) { + return &featuresFile{ + Version: "1.0", + Features: map[string]*featureEntry{}, + }, false, nil + } + + data, err := os.ReadFile(path) + if err != nil { + return nil, true, fmt.Errorf("reading features file: %w", err) + } + + var raw struct { + Version string `yaml:"version"` + Features map[string]struct { + Description string `yaml:"description"` + Files []string `yaml:"files"` + Commit string `yaml:"commit"` + } `yaml:"features"` + } + + if err := yaml.Unmarshal(data, &raw); err != nil { + return nil, true, fmt.Errorf("parsing features file: %w", err) + } + + out := &featuresFile{ + Version: strings.TrimSpace(raw.Version), + Features: map[string]*featureEntry{}, + } + if out.Version == "" { + out.Version = "1.0" + } + + for name, feature := range raw.Features { + out.Features[name] = &featureEntry{ + Description: strings.TrimSpace(feature.Description), + Files: normalizePaths(feature.Files), + Commit: strings.TrimSpace(feature.Commit), + } + } + + return out, true, nil +} + +func saveFeaturesFile(path string, doc *featuresFile) error { + if doc == nil { + return fmt.Errorf("features document is nil") + } + if strings.TrimSpace(doc.Version) == "" { + doc.Version = "1.0" + } + if doc.Features == nil { + doc.Features = map[string]*featureEntry{} + } + + root := &yaml.Node{Kind: yaml.MappingNode} + appendMappingScalar(root, "version", doc.Version) + + featuresNode := &yaml.Node{Kind: yaml.MappingNode} + names := sortedFeatureNames(doc.Features) + for _, name := range names { + feature := doc.Features[name] + if feature == nil { + feature = &featureEntry{} + } + + featureNode := &yaml.Node{Kind: yaml.MappingNode} + if strings.TrimSpace(feature.Description) != "" { + appendMappingScalar(featureNode, "description", strings.TrimSpace(feature.Description)) + } + if strings.TrimSpace(feature.Commit) != "" { + appendMappingScalar(featureNode, "commit", strings.TrimSpace(feature.Commit)) + } + + files := normalizePaths(feature.Files) + filesNode := &yaml.Node{Kind: yaml.SequenceNode} + for _, f := range files { + filesNode.Content = append(filesNode.Content, &yaml.Node{ + Kind: yaml.ScalarNode, + Tag: "!!str", + Value: f, + }) + } + appendMappingNode(featureNode, "files", filesNode) + appendMappingNode(featuresNode, name, featureNode) + } + appendMappingNode(root, "features", featuresNode) + + var buf bytes.Buffer + enc := yaml.NewEncoder(&buf) + enc.SetIndent(2) + if err := enc.Encode(root); err != nil { + return fmt.Errorf("encoding features file: %w", err) + } + _ = enc.Close() + + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return fmt.Errorf("creating features directory: %w", err) + } + if err := os.WriteFile(path, buf.Bytes(), 0o644); err != nil { + return fmt.Errorf("writing features file: %w", err) + } + + return nil +} + +func appendMappingScalar(node *yaml.Node, key, value string) { + appendMappingNode(node, key, &yaml.Node{ + Kind: yaml.ScalarNode, + Tag: "!!str", + Value: value, + }) +} + +func appendMappingNode(node *yaml.Node, key string, value *yaml.Node) { + node.Content = append(node.Content, + &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: key}, + value, + ) +} + +func sortedFeatureNames(features map[string]*featureEntry) []string { + names := make([]string, 0, len(features)) + for name := range features { + names = append(names, name) + } + sort.Strings(names) + return names +} + +func normalizePaths(paths []string) []string { + out := make([]string, 0, len(paths)) + seen := make(map[string]struct{}, len(paths)) + for _, p := range paths { + normalized := filepath.ToSlash(strings.TrimSpace(p)) + if normalized == "" { + continue + } + if _, ok := seen[normalized]; ok { + continue + } + seen[normalized] = struct{}{} + out = append(out, normalized) + } + sort.Strings(out) + return out +} diff --git a/packages/browseros/tools/bros/internal/native/dev/flags.go b/packages/browseros/tools/bros/internal/native/dev/flags.go new file mode 100644 index 00000000..561b6973 --- /dev/null +++ b/packages/browseros/tools/bros/internal/native/dev/flags.go @@ -0,0 +1,98 @@ +package dev + +import ( + "fmt" + "strings" +) + +type addUpdateFlags struct { + Name string + Commit string + Description string +} + +func parseGlobalFlags(args []string) (Options, []string, error) { + var opts Options + + i := 0 + for i < len(args) { + arg := args[i] + if !strings.HasPrefix(arg, "-") { + break + } + + switch { + case arg == "--chromium-src" || arg == "-S": + if i+1 >= len(args) { + return opts, nil, fmt.Errorf("%s requires a value", arg) + } + opts.ChromiumSrc = strings.TrimSpace(args[i+1]) + i += 2 + case strings.HasPrefix(arg, "--chromium-src="): + opts.ChromiumSrc = strings.TrimSpace(strings.TrimPrefix(arg, "--chromium-src=")) + i++ + case arg == "--verbose" || arg == "-v": + opts.Verbose = true + i++ + case arg == "--quiet" || arg == "-q": + opts.Quiet = true + i++ + default: + return opts, nil, fmt.Errorf("unknown dev flag %q", arg) + } + } + + return opts, args[i:], nil +} + +func parseAddUpdateFlags(args []string) (addUpdateFlags, error) { + var out addUpdateFlags + + i := 0 + for i < len(args) { + arg := args[i] + switch { + case arg == "--name" || arg == "-n": + if i+1 >= len(args) { + return out, fmt.Errorf("%s requires a value", arg) + } + out.Name = strings.TrimSpace(args[i+1]) + i += 2 + case strings.HasPrefix(arg, "--name="): + out.Name = strings.TrimSpace(strings.TrimPrefix(arg, "--name=")) + i++ + case arg == "--commit" || arg == "-c": + if i+1 >= len(args) { + return out, fmt.Errorf("%s requires a value", arg) + } + out.Commit = strings.TrimSpace(args[i+1]) + i += 2 + case strings.HasPrefix(arg, "--commit="): + out.Commit = strings.TrimSpace(strings.TrimPrefix(arg, "--commit=")) + i++ + case arg == "--description" || arg == "-d": + if i+1 >= len(args) { + return out, fmt.Errorf("%s requires a value", arg) + } + out.Description = strings.TrimSpace(args[i+1]) + i += 2 + case strings.HasPrefix(arg, "--description="): + out.Description = strings.TrimSpace(strings.TrimPrefix(arg, "--description=")) + i++ + default: + return out, fmt.Errorf("unknown add-update argument %q", arg) + } + } + + if out.Name == "" { + return out, fmt.Errorf("missing required --name") + } + if out.Commit == "" { + return out, fmt.Errorf("missing required --commit") + } + if out.Description == "" { + return out, fmt.Errorf("missing required --description") + } + + return out, nil +} diff --git a/packages/browseros/tools/bros/internal/native/dev/git.go b/packages/browseros/tools/bros/internal/native/dev/git.go new file mode 100644 index 00000000..03315374 --- /dev/null +++ b/packages/browseros/tools/bros/internal/native/dev/git.go @@ -0,0 +1,81 @@ +package dev + +import ( + "bytes" + "errors" + "fmt" + "os/exec" + "sort" + "strings" +) + +type gitResult struct { + Stdout string + Stderr string + ExitCode int +} + +func runGit(cwd string, args ...string) (gitResult, error) { + cmd := exec.Command("git", args...) + cmd.Dir = cwd + + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + result := gitResult{ + Stdout: strings.TrimRight(stdout.String(), "\n"), + Stderr: strings.TrimRight(stderr.String(), "\n"), + } + + if err == nil { + result.ExitCode = 0 + return result, nil + } + + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + result.ExitCode = exitErr.ExitCode() + return result, nil + } + + return result, fmt.Errorf("running git %s: %w", strings.Join(args, " "), err) +} + +// commitChangedFiles returns changed paths in a commit (new path for rename/copy). +func commitChangedFiles(cwd string, commitRef string) ([]string, error) { + result, err := runGit(cwd, "diff-tree", "--no-commit-id", "--name-status", "-r", commitRef) + if err != nil { + return nil, err + } + if result.ExitCode != 0 || strings.TrimSpace(result.Stdout) == "" { + return nil, nil + } + + files := make([]string, 0) + seen := map[string]struct{}{} + for _, line := range strings.Split(result.Stdout, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + parts := strings.Split(line, "\t") + if len(parts) < 2 { + continue + } + filePath := strings.TrimSpace(parts[len(parts)-1]) + if filePath == "" { + continue + } + if _, ok := seen[filePath]; ok { + continue + } + seen[filePath] = struct{}{} + files = append(files, filePath) + } + + sort.Strings(files) + return files, nil +} diff --git a/packages/browseros/tools/bros/internal/native/dev/run.go b/packages/browseros/tools/bros/internal/native/dev/run.go new file mode 100644 index 00000000..6a21ba3b --- /dev/null +++ b/packages/browseros/tools/bros/internal/native/dev/run.go @@ -0,0 +1,88 @@ +package dev + +import ( + "fmt" + "strings" +) + +// Options are top-level flags for `bros dev`. +type Options struct { + ChromiumSrc string + Verbose bool + Quiet bool +} + +// Run executes the native `dev` command family. +// rawArgs may include the leading "dev" token. +func Run(rawArgs []string) error { + args := append([]string(nil), rawArgs...) + if len(args) > 0 && args[0] == "dev" { + args = args[1:] + } + + opts, rest, err := parseGlobalFlags(args) + if err != nil { + return err + } + if len(rest) == 0 { + return fmt.Errorf("dev command requires a subcommand") + } + + ctx, err := newContext(opts) + if err != nil { + return err + } + + switch rest[0] { + case "status": + if len(rest) != 1 { + return fmt.Errorf("status does not accept arguments") + } + return runStatus(ctx) + case "annotate": + if len(rest) > 2 { + return fmt.Errorf("annotate accepts at most one feature name") + } + featureFilter := "" + if len(rest) == 2 { + featureFilter = strings.TrimSpace(rest[1]) + } + return runAnnotate(ctx, featureFilter) + case "feature": + return runFeature(ctx, rest[1:]) + default: + return fmt.Errorf("unknown dev subcommand %q", rest[0]) + } +} + +func runFeature(ctx *Context, args []string) error { + if len(args) == 0 { + return fmt.Errorf("feature requires a subcommand") + } + + switch args[0] { + case "list": + if len(args) != 1 { + return fmt.Errorf("feature list does not accept arguments") + } + return runFeatureList(ctx) + case "show": + if len(args) != 2 { + return fmt.Errorf("usage: feature show ") + } + return runFeatureShow(ctx, args[1]) + case "add-update": + parsed, err := parseAddUpdateFlags(args[1:]) + if err != nil { + return err + } + return runFeatureAddUpdate(ctx, parsed) + case "classify": + if len(args) != 1 { + return fmt.Errorf("feature classify does not accept arguments") + } + return runFeatureClassify(ctx) + default: + return fmt.Errorf("unknown feature subcommand %q", args[0]) + } +} diff --git a/packages/browseros/tools/bros/internal/native/dev/status.go b/packages/browseros/tools/bros/internal/native/dev/status.go new file mode 100644 index 00000000..13f8846d --- /dev/null +++ b/packages/browseros/tools/bros/internal/native/dev/status.go @@ -0,0 +1,57 @@ +package dev + +import ( + "fmt" + "io/fs" + "path/filepath" + "strings" +) + +func runStatus(ctx *Context) error { + fmt.Println("Dev CLI Status") + fmt.Println(strings.Repeat("-", 40)) + fmt.Printf("Chromium source: %s\n", ctx.ChromiumSrc) + + patchesDir := ctx.PatchesDir() + if !isDirectory(patchesDir) { + fmt.Println("No patches directory found") + } else { + patchCount, err := countPatchFiles(patchesDir) + if err != nil { + return err + } + fmt.Printf("Individual patches: %d\n", patchCount) + } + + features, exists, err := loadFeaturesFile(ctx.FeaturesFile()) + if err != nil { + return err + } + if !exists { + fmt.Println("No features.yaml found") + } else { + fmt.Printf("Features defined: %d\n", len(features.Features)) + } + + return nil +} + +func countPatchFiles(root string) (int, error) { + count := 0 + err := filepath.WalkDir(root, func(path string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if d.IsDir() { + return nil + } + if strings.HasSuffix(path, ".patch") { + count++ + } + return nil + }) + if err != nil { + return 0, fmt.Errorf("counting patches in %s: %w", root, err) + } + return count, nil +} diff --git a/packages/browseros/tools/bros/internal/native/dev/validation.go b/packages/browseros/tools/bros/internal/native/dev/validation.go new file mode 100644 index 00000000..c832ec88 --- /dev/null +++ b/packages/browseros/tools/bros/internal/native/dev/validation.go @@ -0,0 +1,50 @@ +package dev + +import ( + "fmt" + "regexp" + "strings" +) + +var validDescriptionPrefixes = []string{"feat:", "fix:", "build:", "chore:", "series:"} + +var featureNamePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`) + +func validateDescription(description string) error { + description = strings.TrimSpace(description) + if description == "" { + return fmt.Errorf("description cannot be empty") + } + + for _, prefix := range validDescriptionPrefixes { + if strings.HasPrefix(description, prefix) { + return nil + } + } + + return fmt.Errorf("description must start with one of: %s", strings.Join(validDescriptionPrefixes, ", ")) +} + +func validateFeatureName(name string) error { + if strings.TrimSpace(name) == "" { + return fmt.Errorf("feature name cannot be empty") + } + + if strings.Contains(name, " ") { + return fmt.Errorf("feature name cannot contain spaces (use hyphens instead)") + } + + if strings.Contains(name, ":") { + return fmt.Errorf("feature name cannot contain ':' (did you pass a description as the name?)") + } + + if name != strings.ToLower(name) { + return fmt.Errorf("feature name must be lowercase (got %q, use %q)", name, strings.ToLower(name)) + } + + if !featureNamePattern.MatchString(name) { + return fmt.Errorf("feature name must start with a letter/number and contain only lowercase letters, numbers, hyphens, and underscores") + } + + return nil +} diff --git a/packages/browseros/tools/bros/internal/native/ota/appcast.go b/packages/browseros/tools/bros/internal/native/ota/appcast.go new file mode 100644 index 00000000..5e31efae --- /dev/null +++ b/packages/browseros/tools/bros/internal/native/ota/appcast.go @@ -0,0 +1,194 @@ +package ota + +import ( + "encoding/xml" + "fmt" + "os" + "path" + "regexp" + "sort" + "strconv" + "strings" + "time" +) + +var platformFromFilenamePattern = regexp.MustCompile(`_([a-z]+_[a-z0-9]+)\.zip$`) + +func parseExistingAppcast(appcastPath string) (*ExistingAppcast, error) { + data, err := os.ReadFile(appcastPath) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("reading appcast %s: %w", appcastPath, err) + } + + var doc appcastRSS + if err := xml.Unmarshal(data, &doc); err != nil { + return nil, fmt.Errorf("malformed appcast XML: %w", err) + } + + if doc.Channel == nil || doc.Channel.Item == nil { + return nil, nil + } + + item := doc.Channel.Item + if strings.TrimSpace(item.Version) == "" { + return nil, nil + } + + artifacts := make(map[string]SignedArtifact) + for _, enclosure := range item.Enclosures { + if enclosure.URL == "" || enclosure.OS == "" || enclosure.Arch == "" || enclosure.Signature == "" { + continue + } + + filename := path.Base(enclosure.URL) + matches := platformFromFilenamePattern.FindStringSubmatch(filename) + if len(matches) != 2 { + continue + } + + length := int64(0) + if strings.TrimSpace(enclosure.Length) != "" { + if parsed, parseErr := strconv.ParseInt(strings.TrimSpace(enclosure.Length), 10, 64); parseErr == nil { + length = parsed + } + } + + platform := matches[1] + artifacts[platform] = SignedArtifact{ + Platform: platform, + ZipPath: filename, + Signature: enclosure.Signature, + Length: length, + OS: enclosure.OS, + Arch: enclosure.Arch, + } + } + + return &ExistingAppcast{ + Version: strings.TrimSpace(item.Version), + PubDate: strings.TrimSpace(item.PubDate), + Artifacts: artifacts, + }, nil +} + +func generateServerAppcast(version string, artifacts []SignedArtifact, channel string, existing *ExistingAppcast) string { + title := "BrowserOS Server" + appcastURL := "https://cdn.browseros.com/appcast-server.xml" + if channel == "alpha" { + title = "BrowserOS Server (Alpha)" + appcastURL = "https://cdn.browseros.com/appcast-server.alpha.xml" + } + + pubDate := time.Now().UTC().Format("Mon, 02 Jan 2006 15:04:05 +0000") + finalArtifacts := make([]SignedArtifact, 0, len(artifacts)) + + if existing != nil && existing.Version == version { + merged := make(map[string]SignedArtifact, len(existing.Artifacts)+len(artifacts)) + for platform, artifact := range existing.Artifacts { + merged[platform] = artifact + } + for _, artifact := range artifacts { + merged[artifact.Platform] = artifact + } + for _, artifact := range merged { + finalArtifacts = append(finalArtifacts, artifact) + } + if existing.PubDate != "" { + pubDate = existing.PubDate + } + } else { + finalArtifacts = append(finalArtifacts, artifacts...) + } + + sort.Slice(finalArtifacts, func(i, j int) bool { + return finalArtifacts[i].Platform < finalArtifacts[j].Platform + }) + + var enclosureBuilder strings.Builder + for idx, artifact := range finalArtifacts { + if idx > 0 { + enclosureBuilder.WriteString("\n\n") + } + + comment := displayOSLabel(artifact.OS) + " " + artifact.Arch + + zipFilename := fmt.Sprintf("browseros_server_%s_%s.zip", version, artifact.Platform) + url := fmt.Sprintf("https://cdn.browseros.com/server/%s", zipFilename) + + enclosureBuilder.WriteString(fmt.Sprintf(" \n", comment)) + enclosureBuilder.WriteString(" ") + } + + return fmt.Sprintf(` + + + %s + %s + BrowserOS Server binary updates + en + + + %s + %s + +%s + + + + +`, sparkleNS, xmlEscape(title), xmlEscape(appcastURL), xmlEscape(version), xmlEscape(pubDate), enclosureBuilder.String()) +} + +type appcastRSS struct { + XMLName xml.Name `xml:"rss"` + Channel *appcastChannel `xml:"channel"` +} + +type appcastChannel struct { + Item *appcastItem `xml:"item"` +} + +type appcastItem struct { + Version string `xml:"http://www.andymatuschak.org/xml-namespaces/sparkle version"` + PubDate string `xml:"pubDate"` + Enclosures []appcastEnclosure `xml:"enclosure"` +} + +type appcastEnclosure struct { + URL string `xml:"url,attr"` + OS string `xml:"http://www.andymatuschak.org/xml-namespaces/sparkle os,attr"` + Arch string `xml:"http://www.andymatuschak.org/xml-namespaces/sparkle arch,attr"` + Signature string `xml:"http://www.andymatuschak.org/xml-namespaces/sparkle edSignature,attr"` + Length string `xml:"length,attr"` +} + +func xmlEscape(value string) string { + var b strings.Builder + _ = xml.EscapeText(&b, []byte(value)) + return b.String() +} + +func displayOSLabel(osName string) string { + switch osName { + case "macos": + return "macOS" + case "windows": + return "Windows" + case "linux": + return "Linux" + default: + if osName == "" { + return "Unknown" + } + return osName + } +} diff --git a/packages/browseros/tools/bros/internal/native/ota/appcast_test.go b/packages/browseros/tools/bros/internal/native/ota/appcast_test.go new file mode 100644 index 00000000..f9fb221f --- /dev/null +++ b/packages/browseros/tools/bros/internal/native/ota/appcast_test.go @@ -0,0 +1,125 @@ +package ota + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestParseExistingAppcast(t *testing.T) { + dir := t.TempDir() + appcastPath := filepath.Join(dir, "appcast.xml") + content := ` + + + + 0.0.41 + Fri, 16 Jan 2026 23:16:56 +0000 + + + +` + if err := os.WriteFile(appcastPath, []byte(content), 0o644); err != nil { + t.Fatalf("write appcast: %v", err) + } + + parsed, err := parseExistingAppcast(appcastPath) + if err != nil { + t.Fatalf("parseExistingAppcast error: %v", err) + } + if parsed == nil { + t.Fatalf("expected parsed appcast") + } + if parsed.Version != "0.0.41" { + t.Fatalf("unexpected version: %s", parsed.Version) + } + if parsed.PubDate != "Fri, 16 Jan 2026 23:16:56 +0000" { + t.Fatalf("unexpected pubDate: %s", parsed.PubDate) + } + artifact, ok := parsed.Artifacts["linux_x64"] + if !ok { + t.Fatalf("expected linux_x64 artifact") + } + if artifact.Length != 123 { + t.Fatalf("unexpected artifact length: %d", artifact.Length) + } +} + +func TestGenerateServerAppcast_MergesSameVersion(t *testing.T) { + existing := &ExistingAppcast{ + Version: "1.2.3", + PubDate: "Fri, 16 Jan 2026 23:16:56 +0000", + Artifacts: map[string]SignedArtifact{ + "linux_x64": { + Platform: "linux_x64", + Signature: "oldsig", + Length: 100, + OS: "linux", + Arch: "x86_64", + }, + }, + } + newArtifacts := []SignedArtifact{ + { + Platform: "darwin_arm64", + Signature: "newsig", + Length: 200, + OS: "macos", + Arch: "arm64", + }, + } + + xml := generateServerAppcast("1.2.3", newArtifacts, "alpha", existing) + if !strings.Contains(xml, "Fri, 16 Jan 2026 23:16:56 +0000") { + t.Fatalf("expected existing pubDate to be preserved") + } + if !strings.Contains(xml, "browseros_server_1.2.3_linux_x64.zip") { + t.Fatalf("expected existing platform to remain in merged appcast") + } + if !strings.Contains(xml, "browseros_server_1.2.3_darwin_arm64.zip") { + t.Fatalf("expected new platform in merged appcast") + } +} + +func TestGenerateServerAppcast_ReplacesOnVersionChange(t *testing.T) { + existing := &ExistingAppcast{ + Version: "1.2.2", + PubDate: "Fri, 16 Jan 2026 23:16:56 +0000", + Artifacts: map[string]SignedArtifact{ + "linux_x64": { + Platform: "linux_x64", + Signature: "oldsig", + Length: 100, + OS: "linux", + Arch: "x86_64", + }, + }, + } + newArtifacts := []SignedArtifact{ + { + Platform: "darwin_arm64", + Signature: "newsig", + Length: 200, + OS: "macos", + Arch: "arm64", + }, + } + + xml := generateServerAppcast("1.2.3", newArtifacts, "prod", existing) + if strings.Contains(xml, "browseros_server_1.2.3_linux_x64.zip") { + t.Fatalf("expected old platform to be removed after version change") + } + if !strings.Contains(xml, "browseros_server_1.2.3_darwin_arm64.zip") { + t.Fatalf("expected new platform in appcast") + } + if !strings.Contains(xml, "https://cdn.browseros.com/appcast-server.xml") { + t.Fatalf("expected production appcast URL") + } +} diff --git a/packages/browseros/tools/bros/internal/native/ota/dispatch.go b/packages/browseros/tools/bros/internal/native/ota/dispatch.go new file mode 100644 index 00000000..5ddbcd8f --- /dev/null +++ b/packages/browseros/tools/bros/internal/native/ota/dispatch.go @@ -0,0 +1,44 @@ +package ota + +import ( + "fmt" + "strings" +) + +// Run handles native OTA subcommands. The bool return indicates whether the +// command was recognized and handled natively. +func Run(args []string, packagesDir string) (bool, error) { + if len(args) == 0 || strings.TrimSpace(args[0]) != "ota" { + return false, nil + } + if len(args) < 2 { + return false, nil + } + + switch strings.TrimSpace(args[1]) { + case "test-signing": + return true, runTestSigning(args[2:], packagesDir) + case "server": + if len(args) < 3 { + return false, nil + } + sub := strings.TrimSpace(args[2]) + subArgs := args[3:] + + switch sub { + case "release": + return true, runServerRelease(subArgs, packagesDir) + case "release-appcast", "publish-appcast": + return true, runServerPublishAppcast(subArgs, packagesDir) + case "list-platforms": + if len(subArgs) > 0 { + return true, fmt.Errorf("list-platforms does not accept arguments") + } + return true, runListPlatforms() + default: + return false, nil + } + default: + return false, nil + } +} diff --git a/packages/browseros/tools/bros/internal/native/ota/list_platforms.go b/packages/browseros/tools/bros/internal/native/ota/list_platforms.go new file mode 100644 index 00000000..cba8acb8 --- /dev/null +++ b/packages/browseros/tools/bros/internal/native/ota/list_platforms.go @@ -0,0 +1,13 @@ +package ota + +import "fmt" + +func runListPlatforms() error { + fmt.Println("Available Server Platforms:") + fmt.Println("--------------------------------------------------") + for _, platform := range serverPlatforms { + fmt.Printf(" %-15s %-10s %s\n", platform.Name, platform.OS, platform.Arch) + } + fmt.Println("--------------------------------------------------") + return nil +} diff --git a/packages/browseros/tools/bros/internal/native/ota/server_publish_appcast.go b/packages/browseros/tools/bros/internal/native/ota/server_publish_appcast.go new file mode 100644 index 00000000..541a6f89 --- /dev/null +++ b/packages/browseros/tools/bros/internal/native/ota/server_publish_appcast.go @@ -0,0 +1,97 @@ +package ota + +import ( + "context" + "flag" + "fmt" + "os" + "path/filepath" + "strings" + + "bros/internal/native/common" +) + +type publishAppcastOptions struct { + Channel string + AppcastFile string +} + +func runServerPublishAppcast(args []string, packagesDir string) error { + opts, err := parsePublishAppcastOptions(args) + if err != nil { + return err + } + + sourcePath := strings.TrimSpace(opts.AppcastFile) + if sourcePath == "" { + sourcePath = appcastPath(packagesDir, opts.Channel) + } + sourcePath = filepath.Clean(sourcePath) + + info, err := os.Stat(sourcePath) + if err != nil { + if os.IsNotExist(err) { + if strings.TrimSpace(opts.AppcastFile) == "" { + return fmt.Errorf("appcast file not found: %s\nrun 'bros ota server release' first to generate the appcast", sourcePath) + } + return fmt.Errorf("appcast file not found: %s", sourcePath) + } + return fmt.Errorf("checking appcast file %s: %w", sourcePath, err) + } + if info.IsDir() { + return fmt.Errorf("appcast path is a directory: %s", sourcePath) + } + + r2Key := "appcast-server.xml" + if opts.Channel == "alpha" { + r2Key = "appcast-server.alpha.xml" + } + + env := common.LoadEnv(packagesDir) + if !env.HasR2Config() { + return fmt.Errorf("R2 configuration not set. Required env vars: R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY") + } + + r2Client, err := common.NewR2Client(env) + if err != nil { + return err + } + + fmt.Printf("Uploading %s to %s...\n", filepath.Base(sourcePath), r2Key) + if err := r2Client.UploadFile(context.Background(), sourcePath, r2Key); err != nil { + return fmt.Errorf("upload failed: %w", err) + } + + fmt.Printf("Published: https://cdn.browseros.com/%s\n", r2Key) + return nil +} + +func parsePublishAppcastOptions(args []string) (publishAppcastOptions, error) { + opts := publishAppcastOptions{} + + fs := flag.NewFlagSet("server-publish-appcast", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + fs.StringVar(&opts.Channel, "channel", "alpha", "release channel: alpha or prod") + fs.StringVar(&opts.Channel, "c", "alpha", "release channel: alpha or prod") + fs.StringVar(&opts.AppcastFile, "file", "", "custom appcast file to upload") + fs.StringVar(&opts.AppcastFile, "f", "", "custom appcast file to upload") + + if err := fs.Parse(args); err != nil { + return publishAppcastOptions{}, err + } + + opts.Channel = strings.TrimSpace(opts.Channel) + if opts.Channel == "" { + opts.Channel = "alpha" + } + if opts.Channel != "alpha" && opts.Channel != "prod" { + return publishAppcastOptions{}, fmt.Errorf("channel must be 'alpha' or 'prod'") + } + + opts.AppcastFile = strings.TrimSpace(opts.AppcastFile) + if fs.NArg() != 0 { + return publishAppcastOptions{}, fmt.Errorf("unexpected positional arguments: %s", strings.Join(fs.Args(), " ")) + } + + return opts, nil +} diff --git a/packages/browseros/tools/bros/internal/native/ota/server_release.go b/packages/browseros/tools/bros/internal/native/ota/server_release.go new file mode 100644 index 00000000..e3198f6d --- /dev/null +++ b/packages/browseros/tools/bros/internal/native/ota/server_release.go @@ -0,0 +1,237 @@ +package ota + +import ( + "context" + "flag" + "fmt" + "os" + "path/filepath" + "runtime" + "sort" + "strings" + + "bros/internal/native/common" +) + +type serverReleaseOptions struct { + Version string + Channel string + BinariesDir string + PlatformList string +} + +func runServerRelease(args []string, packagesDir string) error { + opts, err := parseServerReleaseOptions(args) + if err != nil { + return err + } + + env := common.LoadEnv(packagesDir) + if runtime.GOOS == "darwin" && env.MacOSCertificateName == "" { + return fmt.Errorf("MACOS_CERTIFICATE_NAME required for signing") + } + if runtime.GOOS == "windows" && env.CodeSignToolPath == "" && env.CodeSignToolExe == "" { + return fmt.Errorf("CODE_SIGN_TOOL_PATH or CODE_SIGN_TOOL_EXE required for signing on Windows") + } + if !env.HasR2Config() { + return fmt.Errorf("R2 configuration not set. Required env vars: R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY") + } + + platforms, err := platformsFromFilter(opts.PlatformList) + if err != nil { + return err + } + if len(platforms) == 0 { + return fmt.Errorf("no platforms selected") + } + + binariesDir := opts.BinariesDir + if binariesDir == "" { + binariesDir = filepath.Join(packagesDir, "resources", "binaries", "browseros_server") + } + + if err := validateReleaseInputs(binariesDir, platforms); err != nil { + return err + } + + tempDir, err := os.MkdirTemp("", "bros-server-ota-*") + if err != nil { + return fmt.Errorf("creating temporary directory: %w", err) + } + defer os.RemoveAll(tempDir) + + repoRoot := filepath.Clean(packagesDir) + signedArtifacts := make([]SignedArtifact, 0, len(platforms)) + + for _, platform := range platforms { + fmt.Printf("Processing %s...\n", platform.Name) + + sourceBinary := filepath.Join(binariesDir, platform.Binary) + tempBinary := filepath.Join(tempDir, platform.Binary) + if err := copyFile(sourceBinary, tempBinary); err != nil { + fmt.Printf(" Skipping %s: failed to copy binary: %v\n", platform.Name, err) + continue + } + + if err := signBinary(tempBinary, platform, env, repoRoot); err != nil { + fmt.Printf(" Skipping %s: signing failed: %v\n", platform.Name, err) + continue + } + + zipName := fmt.Sprintf("browseros_server_%s_%s.zip", opts.Version, platform.Name) + zipPath := filepath.Join(tempDir, zipName) + if err := createServerZip(tempBinary, zipPath, platform.OS == "windows"); err != nil { + fmt.Printf(" Skipping %s: failed to create zip: %v\n", platform.Name, err) + continue + } + + signature, length, err := signWithSparkle(zipPath, env) + if err != nil { + fmt.Printf(" Skipping %s: Sparkle signing failed: %v\n", platform.Name, err) + continue + } + + signedArtifacts = append(signedArtifacts, SignedArtifact{ + Platform: platform.Name, + ZipPath: zipPath, + Signature: signature, + Length: length, + OS: platform.OS, + Arch: platform.Arch, + }) + + fmt.Printf(" %s complete (%d bytes)\n", platform.Name, length) + } + + if len(signedArtifacts) == 0 { + return fmt.Errorf("no artifacts were processed successfully") + } + + appcastFile := appcastPath(packagesDir, opts.Channel) + existingAppcast, err := parseExistingAppcast(appcastFile) + if err != nil { + fmt.Printf("Warning: failed to parse existing appcast %s: %v\n", appcastFile, err) + } + + appcastContent := generateServerAppcast(opts.Version, signedArtifacts, opts.Channel, existingAppcast) + if err := os.MkdirAll(filepath.Dir(appcastFile), 0o755); err != nil { + return fmt.Errorf("creating appcast directory: %w", err) + } + if err := os.WriteFile(appcastFile, []byte(appcastContent), 0o644); err != nil { + return fmt.Errorf("writing appcast file: %w", err) + } + fmt.Printf("Appcast saved: %s\n", appcastFile) + + r2Client, err := common.NewR2Client(env) + if err != nil { + return err + } + + for _, artifact := range signedArtifacts { + r2Key := "server/" + filepath.Base(artifact.ZipPath) + fmt.Printf("Uploading %s...\n", r2Key) + if err := r2Client.UploadFile(context.Background(), artifact.ZipPath, r2Key); err != nil { + return fmt.Errorf("failed to upload %s: %w", r2Key, err) + } + } + + sort.Slice(signedArtifacts, func(i, j int) bool { + return signedArtifacts[i].Platform < signedArtifacts[j].Platform + }) + + fmt.Println("Release artifacts ready:") + for _, artifact := range signedArtifacts { + fmt.Printf(" https://cdn.browseros.com/server/%s\n", filepath.Base(artifact.ZipPath)) + } + fmt.Printf("\nNext step: bros ota server publish-appcast --channel %s\n", opts.Channel) + + return nil +} + +func parseServerReleaseOptions(args []string) (serverReleaseOptions, error) { + opts := serverReleaseOptions{} + + fs := flag.NewFlagSet("server-release", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + fs.StringVar(&opts.Version, "version", "", "version to release") + fs.StringVar(&opts.Version, "v", "", "version to release") + fs.StringVar(&opts.Channel, "channel", "alpha", "release channel: alpha or prod") + fs.StringVar(&opts.Channel, "c", "alpha", "release channel: alpha or prod") + fs.StringVar(&opts.BinariesDir, "binaries", "", "directory containing server binaries") + fs.StringVar(&opts.BinariesDir, "b", "", "directory containing server binaries") + fs.StringVar(&opts.PlatformList, "platform", "", "platform(s) to process, comma-separated") + fs.StringVar(&opts.PlatformList, "p", "", "platform(s) to process, comma-separated") + + if err := fs.Parse(args); err != nil { + return serverReleaseOptions{}, err + } + + if strings.TrimSpace(opts.Version) == "" { + return serverReleaseOptions{}, fmt.Errorf("--version is required") + } + opts.Version = strings.TrimSpace(opts.Version) + opts.Channel = strings.TrimSpace(opts.Channel) + if opts.Channel == "" { + opts.Channel = "alpha" + } + if opts.Channel != "alpha" && opts.Channel != "prod" { + return serverReleaseOptions{}, fmt.Errorf("channel must be 'alpha' or 'prod'") + } + opts.BinariesDir = strings.TrimSpace(opts.BinariesDir) + opts.PlatformList = strings.TrimSpace(opts.PlatformList) + + if fs.NArg() != 0 { + return serverReleaseOptions{}, fmt.Errorf("unexpected positional arguments: %s", strings.Join(fs.Args(), " ")) + } + + return opts, nil +} + +func platformsFromFilter(filter string) ([]ServerPlatform, error) { + if strings.TrimSpace(filter) == "" { + return append([]ServerPlatform(nil), serverPlatforms...), nil + } + + parts := strings.Split(filter, ",") + platforms := make([]ServerPlatform, 0, len(parts)) + seen := make(map[string]bool, len(parts)) + for _, part := range parts { + name := strings.TrimSpace(part) + if name == "" || seen[name] { + continue + } + platform, ok := platformByName(name) + if !ok { + return nil, fmt.Errorf("unknown platform: %s", name) + } + seen[name] = true + platforms = append(platforms, platform) + } + + return platforms, nil +} + +func validateReleaseInputs(binariesDir string, platforms []ServerPlatform) error { + info, err := os.Stat(binariesDir) + if err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("binaries directory not found: %s", binariesDir) + } + return fmt.Errorf("checking binaries directory %s: %w", binariesDir, err) + } + if !info.IsDir() { + return fmt.Errorf("binaries path is not a directory: %s", binariesDir) + } + + for _, platform := range platforms { + binaryPath := filepath.Join(binariesDir, platform.Binary) + if _, err := os.Stat(binaryPath); err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("binary not found: %s", binaryPath) + } + return fmt.Errorf("checking binary %s: %w", binaryPath, err) + } + } + + return nil +} diff --git a/packages/browseros/tools/bros/internal/native/ota/sign_binary.go b/packages/browseros/tools/bros/internal/native/ota/sign_binary.go new file mode 100644 index 00000000..33d2d2b5 --- /dev/null +++ b/packages/browseros/tools/bros/internal/native/ota/sign_binary.go @@ -0,0 +1,254 @@ +package ota + +import ( + "bytes" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + + "bros/internal/native/common" +) + +func signBinary(binaryPath string, platform ServerPlatform, env common.EnvConfig, repoRoot string) error { + switch platform.OS { + case "macos": + return signMacOSBinary(binaryPath, env, repoRoot, platform) + case "windows": + return signWindowsBinary(binaryPath, env) + case "linux": + fmt.Printf("No code signing for Linux binary: %s\n", filepath.Base(binaryPath)) + return nil + default: + return nil + } +} + +func signMacOSBinary(binaryPath string, env common.EnvConfig, repoRoot string, platform ServerPlatform) error { + if runtime.GOOS != "darwin" { + fmt.Printf("macOS signing requires macOS - skipping %s\n", platform.Name) + return nil + } + + if env.MacOSCertificateName == "" { + return fmt.Errorf("MACOS_CERTIFICATE_NAME required for signing on macOS") + } + + args := []string{ + "--sign", env.MacOSCertificateName, + "--force", + "--timestamp", + "--identifier", "com.browseros." + strings.TrimSuffix(filepath.Base(binaryPath), filepath.Ext(binaryPath)), + "--options", "runtime", + } + + if entitlements := findEntitlementsPath(repoRoot); entitlements != "" { + args = append(args, "--entitlements", entitlements) + } + args = append(args, binaryPath) + + if output, err := runCommandCapture("", "codesign", args...); err != nil { + return fmt.Errorf("codesign failed for %s: %s", filepath.Base(binaryPath), strings.TrimSpace(output)) + } + + if output, err := runCommandCapture("", "codesign", "--verify", "--verbose=2", binaryPath); err != nil { + return fmt.Errorf("codesign verification failed for %s: %s", filepath.Base(binaryPath), strings.TrimSpace(output)) + } + + if err := notarizeMacOSBinary(binaryPath, env); err != nil { + return err + } + + return nil +} + +func notarizeMacOSBinary(binaryPath string, env common.EnvConfig) error { + if runtime.GOOS != "darwin" { + return nil + } + + if env.MacOSNotarizationAppleID == "" || env.MacOSNotarizationTeamID == "" || env.MacOSNotarizationPassword == "" { + return fmt.Errorf("missing notarization credentials: PROD_MACOS_NOTARIZATION_APPLE_ID, PROD_MACOS_NOTARIZATION_TEAM_ID, PROD_MACOS_NOTARIZATION_PWD") + } + + tempZip, err := os.CreateTemp("", "browseros-notary-*.zip") + if err != nil { + return fmt.Errorf("creating temporary zip for notarization: %w", err) + } + tempZipPath := tempZip.Name() + _ = tempZip.Close() + defer os.Remove(tempZipPath) + + if output, err := runCommandCapture("", "ditto", "-c", "-k", "--keepParent", binaryPath, tempZipPath); err != nil { + return fmt.Errorf("failed to create notarization zip: %s", strings.TrimSpace(output)) + } + + _, _ = runCommandCapture("", "xcrun", "notarytool", "store-credentials", "notarytool-profile", + "--apple-id", env.MacOSNotarizationAppleID, + "--team-id", env.MacOSNotarizationTeamID, + "--password", env.MacOSNotarizationPassword, + ) + + output, err := runCommandCapture("", "xcrun", "notarytool", "submit", tempZipPath, + "--keychain-profile", "notarytool-profile", + "--wait", + ) + if err != nil { + return fmt.Errorf("notarization failed: %s", strings.TrimSpace(output)) + } + if !strings.Contains(output, "status: Accepted") { + return fmt.Errorf("notarization was not accepted: %s", strings.TrimSpace(output)) + } + + return nil +} + +func signWindowsBinary(binaryPath string, env common.EnvConfig) error { + toolPath := "" + switch { + case env.CodeSignToolExe != "": + toolPath = env.CodeSignToolExe + case env.CodeSignToolPath != "": + toolPath = filepath.Join(env.CodeSignToolPath, "CodeSignTool.bat") + default: + fmt.Printf("CODE_SIGN_TOOL_EXE not set - skipping Windows signing for %s\n", filepath.Base(binaryPath)) + return nil + } + + if _, err := os.Stat(toolPath); err != nil { + return fmt.Errorf("CodeSignTool not found at %s", toolPath) + } + + if env.ESignerUsername == "" || env.ESignerPassword == "" || env.ESignerTOTPSecret == "" { + return fmt.Errorf("missing eSigner credentials: ESIGNER_USERNAME, ESIGNER_PASSWORD, ESIGNER_TOTP_SECRET") + } + + tempOutputDir := filepath.Join(filepath.Dir(binaryPath), "signed_temp") + if err := os.MkdirAll(tempOutputDir, 0o755); err != nil { + return fmt.Errorf("creating temporary signing directory: %w", err) + } + defer os.RemoveAll(tempOutputDir) + + args := []string{ + "sign", + "-username", env.ESignerUsername, + "-password", env.ESignerPassword, + } + if env.ESignerCredentialID != "" { + args = append(args, "-credential_id", env.ESignerCredentialID) + } + args = append(args, + "-totp_secret", env.ESignerTOTPSecret, + "-input_file_path", binaryPath, + "-output_dir_path", tempOutputDir, + "-override", + ) + + output, err := runToolCapture(toolPath, filepath.Dir(toolPath), args...) + if err != nil { + return fmt.Errorf("CodeSignTool failed: %s", strings.TrimSpace(output)) + } + if strings.Contains(output, "Error:") { + return fmt.Errorf("CodeSignTool reported error: %s", strings.TrimSpace(output)) + } + + signedPath := filepath.Join(tempOutputDir, filepath.Base(binaryPath)) + if _, err := os.Stat(signedPath); err == nil { + if err := os.Rename(signedPath, binaryPath); err != nil { + if copyErr := copyFile(signedPath, binaryPath); copyErr != nil { + return fmt.Errorf("moving signed binary into place: %w", err) + } + } + } + + if runtime.GOOS == "windows" { + pathForPS := strings.ReplaceAll(binaryPath, "'", "''") + verifyOutput, verifyErr := runCommandCapture("", "powershell", "-Command", "(Get-AuthenticodeSignature '"+pathForPS+"').Status") + if verifyErr != nil { + return fmt.Errorf("signature verification failed: %s", strings.TrimSpace(verifyOutput)) + } + if !strings.Contains(strings.TrimSpace(verifyOutput), "Valid") { + return fmt.Errorf("signature verification failed: %s", strings.TrimSpace(verifyOutput)) + } + } + + return nil +} + +func findEntitlementsPath(repoRoot string) string { + candidates := []string{ + filepath.Join(repoRoot, "resources", "entitlements", "browseros-executable-entitlements.plist"), + filepath.Join(repoRoot, "packages", "browseros", "resources", "entitlements", "browseros-executable-entitlements.plist"), + } + + for _, candidate := range candidates { + if _, err := os.Stat(candidate); err == nil { + return candidate + } + } + + return "" +} + +func runToolCapture(toolPath string, workingDir string, args ...string) (string, error) { + if runtime.GOOS == "windows" && strings.EqualFold(filepath.Ext(toolPath), ".bat") { + cmdArgs := append([]string{"/C", toolPath}, args...) + return runCommandCapture(workingDir, "cmd", cmdArgs...) + } + + return runCommandCapture(workingDir, toolPath, args...) +} + +func runCommandCapture(workingDir string, name string, args ...string) (string, error) { + cmd := exec.Command(name, args...) + if workingDir != "" { + cmd.Dir = workingDir + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + output := strings.TrimSpace(stdout.String()) + if serr := strings.TrimSpace(stderr.String()); serr != "" { + if output == "" { + output = serr + } else { + output = output + "\n" + serr + } + } + + if err != nil { + return output, err + } + return output, nil +} + +func copyFile(srcPath string, dstPath string) error { + src, err := os.Open(srcPath) + if err != nil { + return err + } + defer src.Close() + + info, err := src.Stat() + if err != nil { + return err + } + + dst, err := os.OpenFile(dstPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, info.Mode()) + if err != nil { + return err + } + defer dst.Close() + + if _, err := dst.ReadFrom(src); err != nil { + return err + } + + return nil +} diff --git a/packages/browseros/tools/bros/internal/native/ota/sparkle.go b/packages/browseros/tools/bros/internal/native/ota/sparkle.go new file mode 100644 index 00000000..4ac79fa6 --- /dev/null +++ b/packages/browseros/tools/bros/internal/native/ota/sparkle.go @@ -0,0 +1,71 @@ +package ota + +import ( + "crypto/ed25519" + "encoding/base64" + "fmt" + "os" + "strings" + + "bros/internal/native/common" +) + +func signWithSparkle(filePath string, env common.EnvConfig) (string, int64, error) { + if !env.HasSparkleKey() { + return "", 0, fmt.Errorf("SPARKLE_PRIVATE_KEY not set") + } + + privateKey, err := parseSparklePrivateKey(env.SparklePrivateKey) + if err != nil { + return "", 0, err + } + + fileData, err := os.ReadFile(filePath) + if err != nil { + return "", 0, fmt.Errorf("reading %s: %w", filePath, err) + } + + signature := ed25519.Sign(privateKey, fileData) + return base64.StdEncoding.EncodeToString(signature), int64(len(fileData)), nil +} + +func parseSparklePrivateKey(keyData string) (ed25519.PrivateKey, error) { + keyData = strings.TrimSpace(keyData) + if keyData == "" { + return nil, fmt.Errorf("SPARKLE_PRIVATE_KEY is empty") + } + + keyBytes, decoded := decodePossiblyBase64(keyData) + if !decoded { + keyBytes = []byte(keyData) + } + + switch len(keyBytes) { + case ed25519.SeedSize: + return ed25519.NewKeyFromSeed(keyBytes), nil + case ed25519.PrivateKeySize: + return ed25519.NewKeyFromSeed(keyBytes[:ed25519.SeedSize]), nil + default: + return nil, fmt.Errorf("invalid Sparkle key length: %d bytes (expected 32 or 64)", len(keyBytes)) + } +} + +func decodePossiblyBase64(input string) ([]byte, bool) { + if decoded, err := base64.StdEncoding.DecodeString(input); err == nil { + return decoded, true + } + + if decoded, err := base64.RawStdEncoding.DecodeString(input); err == nil { + return decoded, true + } + + padded := input + if rem := len(input) % 4; rem != 0 { + padded = input + strings.Repeat("=", 4-rem) + if decoded, err := base64.StdEncoding.DecodeString(padded); err == nil { + return decoded, true + } + } + + return nil, false +} diff --git a/packages/browseros/tools/bros/internal/native/ota/sparkle_test.go b/packages/browseros/tools/bros/internal/native/ota/sparkle_test.go new file mode 100644 index 00000000..4ed19880 --- /dev/null +++ b/packages/browseros/tools/bros/internal/native/ota/sparkle_test.go @@ -0,0 +1,83 @@ +package ota + +import ( + "crypto/ed25519" + "encoding/base64" + "os" + "path/filepath" + "testing" + + "bros/internal/native/common" +) + +func TestParseSparklePrivateKey_Base64Seed32(t *testing.T) { + seed := make([]byte, ed25519.SeedSize) + for i := range seed { + seed[i] = byte(i) + } + + encoded := base64.StdEncoding.EncodeToString(seed) + parsed, err := parseSparklePrivateKey(encoded) + if err != nil { + t.Fatalf("parseSparklePrivateKey returned error: %v", err) + } + + expected := ed25519.NewKeyFromSeed(seed) + if string(parsed) != string(expected) { + t.Fatalf("parsed key mismatch") + } +} + +func TestParseSparklePrivateKey_Base64Sparkle64(t *testing.T) { + seed := make([]byte, ed25519.SeedSize) + for i := range seed { + seed[i] = byte(i + 1) + } + + privateKey := ed25519.NewKeyFromSeed(seed) + combined := make([]byte, 0, ed25519.PrivateKeySize) + combined = append(combined, seed...) + combined = append(combined, privateKey.Public().(ed25519.PublicKey)...) + + encoded := base64.StdEncoding.EncodeToString(combined) + parsed, err := parseSparklePrivateKey(encoded) + if err != nil { + t.Fatalf("parseSparklePrivateKey returned error: %v", err) + } + + if string(parsed) != string(privateKey) { + t.Fatalf("parsed key mismatch") + } +} + +func TestParseSparklePrivateKey_InvalidLength(t *testing.T) { + encoded := base64.StdEncoding.EncodeToString([]byte("too-short")) + if _, err := parseSparklePrivateKey(encoded); err == nil { + t.Fatalf("expected invalid length error") + } +} + +func TestSignWithSparkle(t *testing.T) { + seed := make([]byte, ed25519.SeedSize) + for i := range seed { + seed[i] = byte(i + 2) + } + env := common.EnvConfig{SparklePrivateKey: base64.StdEncoding.EncodeToString(seed)} + + dir := t.TempDir() + filePath := filepath.Join(dir, "artifact.zip") + if err := os.WriteFile(filePath, []byte("hello world"), 0o644); err != nil { + t.Fatalf("write test artifact: %v", err) + } + + signature, length, err := signWithSparkle(filePath, env) + if err != nil { + t.Fatalf("signWithSparkle returned error: %v", err) + } + if signature == "" { + t.Fatalf("signature should not be empty") + } + if length != int64(len("hello world")) { + t.Fatalf("unexpected length: %d", length) + } +} diff --git a/packages/browseros/tools/bros/internal/native/ota/test_signing.go b/packages/browseros/tools/bros/internal/native/ota/test_signing.go new file mode 100644 index 00000000..ba3bede6 --- /dev/null +++ b/packages/browseros/tools/bros/internal/native/ota/test_signing.go @@ -0,0 +1,43 @@ +package ota + +import ( + "fmt" + "os" + "path/filepath" + + "bros/internal/native/common" +) + +func runTestSigning(args []string, packagesDir string) error { + if len(args) != 1 { + return fmt.Errorf("usage: bros ota test-signing ") + } + + filePath := filepath.Clean(args[0]) + info, err := os.Stat(filePath) + if err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("file not found: %s", filePath) + } + return fmt.Errorf("checking file %s: %w", filePath, err) + } + if info.IsDir() { + return fmt.Errorf("path is a directory: %s", filePath) + } + + env := common.LoadEnv(packagesDir) + signature, length, err := signWithSparkle(filePath, env) + if err != nil { + return err + } + + preview := signature + if len(preview) > 50 { + preview = preview[:50] + "..." + } + + fmt.Println("Signed successfully") + fmt.Printf("Signature: %s\n", preview) + fmt.Printf("Length: %d\n", length) + return nil +} diff --git a/packages/browseros/tools/bros/internal/native/ota/types.go b/packages/browseros/tools/bros/internal/native/ota/types.go new file mode 100644 index 00000000..79e25d6c --- /dev/null +++ b/packages/browseros/tools/bros/internal/native/ota/types.go @@ -0,0 +1,53 @@ +package ota + +import "path/filepath" + +const ( + sparkleNS = "http://www.andymatuschak.org/xml-namespaces/sparkle" +) + +type ServerPlatform struct { + Name string + Binary string + OS string + Arch string +} + +var serverPlatforms = []ServerPlatform{ + {Name: "darwin_arm64", Binary: "browseros-server-darwin-arm64", OS: "macos", Arch: "arm64"}, + {Name: "darwin_x64", Binary: "browseros-server-darwin-x64", OS: "macos", Arch: "x86_64"}, + {Name: "linux_arm64", Binary: "browseros-server-linux-arm64", OS: "linux", Arch: "arm64"}, + {Name: "linux_x64", Binary: "browseros-server-linux-x64", OS: "linux", Arch: "x86_64"}, + {Name: "windows_x64", Binary: "browseros-server-windows-x64.exe", OS: "windows", Arch: "x86_64"}, +} + +type SignedArtifact struct { + Platform string + ZipPath string + Signature string + Length int64 + OS string + Arch string +} + +type ExistingAppcast struct { + Version string + PubDate string + Artifacts map[string]SignedArtifact +} + +func appcastPath(packagesDir string, channel string) string { + if channel == "alpha" { + return filepath.Join(packagesDir, "build", "config", "appcast", "appcast-server.alpha.xml") + } + return filepath.Join(packagesDir, "build", "config", "appcast", "appcast-server.xml") +} + +func platformByName(name string) (ServerPlatform, bool) { + for _, platform := range serverPlatforms { + if platform.Name == name { + return platform, true + } + } + return ServerPlatform{}, false +} diff --git a/packages/browseros/tools/bros/internal/native/ota/zip.go b/packages/browseros/tools/bros/internal/native/ota/zip.go new file mode 100644 index 00000000..80723659 --- /dev/null +++ b/packages/browseros/tools/bros/internal/native/ota/zip.go @@ -0,0 +1,82 @@ +package ota + +import ( + "archive/zip" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +func createServerZip(binaryPath string, outputZip string, isWindows bool) error { + stagingDir := filepath.Join(filepath.Dir(outputZip), "staging_"+strings.TrimSuffix(filepath.Base(outputZip), filepath.Ext(outputZip))) + defer os.RemoveAll(stagingDir) + + binDir := filepath.Join(stagingDir, "resources", "bin") + if err := os.MkdirAll(binDir, 0o755); err != nil { + return fmt.Errorf("creating staging directory: %w", err) + } + + targetName := "browseros_server" + if isWindows { + targetName = "browseros_server.exe" + } + + targetBinary := filepath.Join(binDir, targetName) + if err := copyFile(binaryPath, targetBinary); err != nil { + return fmt.Errorf("copying binary into zip staging area: %w", err) + } + + zipFile, err := os.Create(outputZip) + if err != nil { + return fmt.Errorf("creating zip file %s: %w", outputZip, err) + } + defer zipFile.Close() + + zipWriter := zip.NewWriter(zipFile) + defer zipWriter.Close() + + if err := filepath.Walk(stagingDir, func(path string, info os.FileInfo, walkErr error) error { + if walkErr != nil { + return walkErr + } + if info.IsDir() { + return nil + } + + relPath, err := filepath.Rel(stagingDir, path) + if err != nil { + return err + } + relPath = filepath.ToSlash(relPath) + + header, err := zip.FileInfoHeader(info) + if err != nil { + return err + } + header.Name = relPath + header.Method = zip.Deflate + header.SetMode(info.Mode()) + + writer, err := zipWriter.CreateHeader(header) + if err != nil { + return err + } + + file, err := os.Open(path) + if err != nil { + return err + } + + if _, err := io.Copy(writer, file); err != nil { + _ = file.Close() + return err + } + return file.Close() + }); err != nil { + return fmt.Errorf("writing zip contents: %w", err) + } + + return nil +} diff --git a/packages/browseros/tools/bros/internal/native/r2/client.go b/packages/browseros/tools/bros/internal/native/r2/client.go new file mode 100644 index 00000000..68e011a7 --- /dev/null +++ b/packages/browseros/tools/bros/internal/native/r2/client.go @@ -0,0 +1,325 @@ +package r2 + +import ( + "context" + "errors" + "fmt" + "io" + "net/url" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/aws/aws-sdk-go-v2/service/s3/types" + "github.com/aws/smithy-go" +) + +const ( + R2AccountIDEnv = "R2_ACCOUNT_ID" + R2AccessKeyIDEnv = "R2_ACCESS_KEY_ID" + R2SecretAccessKeyEnv = "R2_SECRET_ACCESS_KEY" + R2BucketEnv = "R2_BUCKET" + R2CDNBaseURLEnv = "R2_CDN_BASE_URL" +) + +const ( + DefaultR2Bucket = "browseros" + DefaultR2CDNBaseURL = "http://cdn.browseros.com" +) + +var ErrNotFound = errors.New("r2 object not found") + +type Config struct { + AccountID string + AccessKeyID string + SecretAccessKey string + Bucket string + CDNBaseURL string + EndpointURL string +} + +type Client struct { + s3 *s3.Client + bucket string + cdnBaseURL string +} + +func NewClientFromEnv() (*Client, error) { + return NewClient(LoadConfigFromEnv()) +} + +func NewClient(cfg Config) (*Client, error) { + if err := cfg.Validate(); err != nil { + return nil, err + } + + awsCfg := aws.Config{ + Region: "auto", + Credentials: aws.NewCredentialsCache(credentials.NewStaticCredentialsProvider(cfg.AccessKeyID, cfg.SecretAccessKey, "")), + } + + s3Client := s3.NewFromConfig(awsCfg, func(opts *s3.Options) { + opts.BaseEndpoint = aws.String(cfg.EndpointURL) + opts.UsePathStyle = true + }) + + return &Client{ + s3: s3Client, + bucket: cfg.Bucket, + cdnBaseURL: strings.TrimRight(cfg.CDNBaseURL, "/"), + }, nil +} + +func LoadConfigFromEnv() Config { + return LoadConfig(os.Getenv) +} + +func LoadConfig(lookup func(string) string) Config { + accountID := strings.TrimSpace(lookup(R2AccountIDEnv)) + accessKey := strings.TrimSpace(lookup(R2AccessKeyIDEnv)) + secretKey := strings.TrimSpace(lookup(R2SecretAccessKeyEnv)) + bucket := strings.TrimSpace(lookup(R2BucketEnv)) + cdnBase := strings.TrimSpace(lookup(R2CDNBaseURLEnv)) + + if bucket == "" { + bucket = DefaultR2Bucket + } + if cdnBase == "" { + cdnBase = DefaultR2CDNBaseURL + } + + cfg := Config{ + AccountID: accountID, + AccessKeyID: accessKey, + SecretAccessKey: secretKey, + Bucket: bucket, + CDNBaseURL: cdnBase, + } + if accountID != "" { + cfg.EndpointURL = fmt.Sprintf("https://%s.r2.cloudflarestorage.com", accountID) + } + + return cfg +} + +func (c Config) HasConfig() bool { + return c.AccountID != "" && c.AccessKeyID != "" && c.SecretAccessKey != "" +} + +func (c Config) Validate() error { + missing := make([]string, 0, 3) + if c.AccountID == "" { + missing = append(missing, R2AccountIDEnv) + } + if c.AccessKeyID == "" { + missing = append(missing, R2AccessKeyIDEnv) + } + if c.SecretAccessKey == "" { + missing = append(missing, R2SecretAccessKeyEnv) + } + if len(missing) > 0 { + return fmt.Errorf("R2 configuration not set (missing: %s)", strings.Join(missing, ", ")) + } + if c.EndpointURL == "" { + return fmt.Errorf("R2 endpoint URL is empty") + } + if c.Bucket == "" { + return fmt.Errorf("R2 bucket is empty") + } + return nil +} + +func (c *Client) Bucket() string { + return c.bucket +} + +func (c *Client) CDNBaseURL() string { + return c.cdnBaseURL +} + +func (c *Client) ListReleaseVersions(ctx context.Context) ([]string, error) { + versions := make(map[string]struct{}) + var token *string + + for { + resp, err := c.s3.ListObjectsV2(ctx, &s3.ListObjectsV2Input{ + Bucket: aws.String(c.bucket), + Prefix: aws.String("releases/"), + Delimiter: aws.String("/"), + ContinuationToken: token, + }) + if err != nil { + return nil, err + } + + for _, prefix := range resp.CommonPrefixes { + version := strings.TrimSuffix(strings.TrimPrefix(aws.ToString(prefix.Prefix), "releases/"), "/") + if version != "" { + versions[version] = struct{}{} + } + } + + if !aws.ToBool(resp.IsTruncated) { + break + } + token = resp.NextContinuationToken + } + + out := make([]string, 0, len(versions)) + for version := range versions { + out = append(out, version) + } + SortVersionsDesc(out) + return out, nil +} + +func SortVersionsDesc(versions []string) { + sort.Slice(versions, func(i, j int) bool { + return CompareVersions(versions[i], versions[j]) > 0 + }) +} + +// CompareVersions compares semantic-like versions. +// Returns 1 when a > b, -1 when a < b, and 0 when equal. +func CompareVersions(a, b string) int { + aParts := trimTrailingZeros(parseVersionParts(a)) + bParts := trimTrailingZeros(parseVersionParts(b)) + + limit := len(aParts) + if len(bParts) > limit { + limit = len(bParts) + } + + for i := 0; i < limit; i++ { + aVal := 0 + if i < len(aParts) { + aVal = aParts[i] + } + bVal := 0 + if i < len(bParts) { + bVal = bParts[i] + } + + if aVal > bVal { + return 1 + } + if aVal < bVal { + return -1 + } + } + + return 0 +} + +func parseVersionParts(version string) []int { + parts := strings.Split(version, ".") + out := make([]int, 0, len(parts)) + for _, part := range parts { + value, err := strconv.Atoi(part) + if err != nil { + value = 0 + } + out = append(out, value) + } + return out +} + +func trimTrailingZeros(values []int) []int { + last := len(values) - 1 + for last >= 0 && values[last] == 0 { + last-- + } + if last < 0 { + return []int{0} + } + return values[:last+1] +} + +func (c *Client) FetchReleaseJSON(ctx context.Context, version, platform string) ([]byte, error) { + key := fmt.Sprintf("releases/%s/%s/release.json", version, platform) + return c.GetObject(ctx, key) +} + +func (c *Client) GetObject(ctx context.Context, key string) ([]byte, error) { + resp, err := c.s3.GetObject(ctx, &s3.GetObjectInput{ + Bucket: aws.String(c.bucket), + Key: aws.String(key), + }) + if err != nil { + if isNoSuchKey(err) { + return nil, ErrNotFound + } + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + return body, nil +} + +func (c *Client) CopyObject(ctx context.Context, sourceKey, destinationKey string) error { + _, err := c.s3.CopyObject(ctx, &s3.CopyObjectInput{ + Bucket: aws.String(c.bucket), + Key: aws.String(destinationKey), + CopySource: aws.String(copySource(c.bucket, sourceKey)), + }) + return err +} + +func (c *Client) DownloadObject(ctx context.Context, key, destinationPath string) error { + resp, err := c.s3.GetObject(ctx, &s3.GetObjectInput{ + Bucket: aws.String(c.bucket), + Key: aws.String(key), + }) + if err != nil { + if isNoSuchKey(err) { + return ErrNotFound + } + return err + } + defer resp.Body.Close() + + if err := os.MkdirAll(filepath.Dir(destinationPath), 0o755); err != nil { + return err + } + + file, err := os.Create(destinationPath) + if err != nil { + return err + } + defer file.Close() + + if _, err := io.Copy(file, resp.Body); err != nil { + return err + } + + return nil +} + +func copySource(bucket, key string) string { + escapedKey := strings.ReplaceAll(url.PathEscape(key), "%2F", "/") + return bucket + "/" + escapedKey +} + +func isNoSuchKey(err error) bool { + var notFound *types.NoSuchKey + if errors.As(err, ¬Found) { + return true + } + + var apiErr smithy.APIError + if errors.As(err, &apiErr) { + code := apiErr.ErrorCode() + return code == "NoSuchKey" || code == "NotFound" || code == "404" + } + + return false +} diff --git a/packages/browseros/tools/bros/internal/native/r2/client_test.go b/packages/browseros/tools/bros/internal/native/r2/client_test.go new file mode 100644 index 00000000..9ffda03c --- /dev/null +++ b/packages/browseros/tools/bros/internal/native/r2/client_test.go @@ -0,0 +1,82 @@ +package r2 + +import ( + "reflect" + "testing" +) + +func TestLoadConfigDefaults(t *testing.T) { + cfg := LoadConfig(func(string) string { return "" }) + + if cfg.Bucket != DefaultR2Bucket { + t.Fatalf("expected bucket %q, got %q", DefaultR2Bucket, cfg.Bucket) + } + if cfg.CDNBaseURL != DefaultR2CDNBaseURL { + t.Fatalf("expected cdn base %q, got %q", DefaultR2CDNBaseURL, cfg.CDNBaseURL) + } + if cfg.EndpointURL != "" { + t.Fatalf("expected empty endpoint URL, got %q", cfg.EndpointURL) + } +} + +func TestLoadConfigEndpointDerivedFromAccountID(t *testing.T) { + cfg := LoadConfig(func(key string) string { + switch key { + case R2AccountIDEnv: + return "abc123" + case R2AccessKeyIDEnv: + return "access" + case R2SecretAccessKeyEnv: + return "secret" + default: + return "" + } + }) + + if cfg.EndpointURL != "https://abc123.r2.cloudflarestorage.com" { + t.Fatalf("unexpected endpoint: %q", cfg.EndpointURL) + } + if !cfg.HasConfig() { + t.Fatal("expected HasConfig true") + } + if err := cfg.Validate(); err != nil { + t.Fatalf("Validate returned error: %v", err) + } +} + +func TestSortVersionsDesc(t *testing.T) { + versions := []string{"0.9.0", "0.10.0", "1.0.0", "0.31.0", "0.31.0.1"} + + SortVersionsDesc(versions) + + expected := []string{"1.0.0", "0.31.0.1", "0.31.0", "0.10.0", "0.9.0"} + if !reflect.DeepEqual(versions, expected) { + t.Fatalf("unexpected order: got %v want %v", versions, expected) + } +} + +func TestCompareVersions(t *testing.T) { + cases := []struct { + a, b string + cmp int + }{ + {a: "0.31.0", b: "0.30.9", cmp: 1}, + {a: "0.31.0", b: "0.31.0", cmp: 0}, + {a: "0.31.0", b: "0.31.1", cmp: -1}, + {a: "1", b: "1.0.0", cmp: 0}, + } + + for _, tc := range cases { + if got := CompareVersions(tc.a, tc.b); got != tc.cmp { + t.Fatalf("CompareVersions(%q, %q)=%d want %d", tc.a, tc.b, got, tc.cmp) + } + } +} + +func TestCopySourceEscapesKey(t *testing.T) { + got := copySource("bucket", "releases/0.31.0/macos/Browser OS.dmg") + want := "bucket/releases/0.31.0/macos/Browser%20OS.dmg" + if got != want { + t.Fatalf("copySource mismatch: got %q want %q", got, want) + } +} diff --git a/packages/browseros/tools/bros/internal/native/release/appcast.go b/packages/browseros/tools/bros/internal/native/release/appcast.go new file mode 100644 index 00000000..bf02e5ad --- /dev/null +++ b/packages/browseros/tools/bros/internal/native/release/appcast.go @@ -0,0 +1,75 @@ +package release + +import ( + "fmt" + "time" +) + +type AppcastSnippet struct { + Arch string + Filename string + ItemXML string +} + +func GenerateAppcastItem(artifact Artifact, version, sparkleVersion, buildDate string) string { + signature := artifact.SparkleSignature + length := artifact.Size + if artifact.SparkleLength > 0 { + length = artifact.SparkleLength + } + + return fmt.Sprintf(` + BrowserOS - %s + + + %s + %s + %s + https://browseros.com + + 10.15 +`, version, sparkleVersion, version, formatPubDate(buildDate), artifact.URL, signature, length) +} + +func GenerateAppcastSnippets(version string, macOSMetadata PlatformMetadata) []AppcastSnippet { + archToFile := map[string]string{ + "arm64": "appcast.xml", + "x64": "appcast-x86_64.xml", + "universal": "appcast.xml", + } + + orderedArch := []string{"arm64", "x64", "universal"} + out := make([]AppcastSnippet, 0, len(orderedArch)) + + for _, arch := range orderedArch { + artifact, ok := macOSMetadata.Artifacts[arch] + if !ok { + continue + } + out = append(out, AppcastSnippet{ + Arch: arch, + Filename: archToFile[arch], + ItemXML: GenerateAppcastItem(artifact, version, macOSMetadata.SparkleVersion, macOSMetadata.BuildDate), + }) + } + + return out +} + +func formatPubDate(buildDate string) string { + if buildDate == "" { + return "" + } + + for _, layout := range []string{time.RFC3339Nano, time.RFC3339} { + if parsed, err := time.Parse(layout, buildDate); err == nil { + return parsed.Format("Mon, 02 Jan 2006 15:04:05 -0700") + } + } + + return buildDate +} diff --git a/packages/browseros/tools/bros/internal/native/release/appcast_test.go b/packages/browseros/tools/bros/internal/native/release/appcast_test.go new file mode 100644 index 00000000..ea696436 --- /dev/null +++ b/packages/browseros/tools/bros/internal/native/release/appcast_test.go @@ -0,0 +1,46 @@ +package release + +import ( + "strings" + "testing" +) + +func TestGenerateAppcastItemUsesSparkleLength(t *testing.T) { + artifact := Artifact{ + URL: "https://cdn.browseros.com/a.dmg", + Size: 100, + SparkleLength: 200, + SparkleSignature: "abc123", + } + + xml := GenerateAppcastItem(artifact, "0.31.0", "120.1", "2026-01-02T03:04:05Z") + + if !strings.Contains(xml, `length="200"`) { + t.Fatalf("expected sparkle length in xml, got %s", xml) + } + if !strings.Contains(xml, "Fri, 02 Jan 2026 03:04:05 +0000") { + t.Fatalf("expected RFC822 pubDate, got %s", xml) + } +} + +func TestGenerateAppcastSnippetsOrder(t *testing.T) { + metadata := PlatformMetadata{ + SparkleVersion: "120.1", + BuildDate: "2026-01-02T03:04:05Z", + Artifacts: map[string]Artifact{ + "universal": {URL: "https://cdn/universal", Filename: "u"}, + "arm64": {URL: "https://cdn/arm", Filename: "a"}, + }, + } + + snippets := GenerateAppcastSnippets("0.31.0", metadata) + if len(snippets) != 2 { + t.Fatalf("expected 2 snippets, got %d", len(snippets)) + } + if snippets[0].Arch != "arm64" { + t.Fatalf("expected arm64 first, got %q", snippets[0].Arch) + } + if snippets[1].Arch != "universal" { + t.Fatalf("expected universal second, got %q", snippets[1].Arch) + } +} diff --git a/packages/browseros/tools/bros/internal/native/release/download.go b/packages/browseros/tools/bros/internal/native/release/download.go new file mode 100644 index 00000000..cf99bff2 --- /dev/null +++ b/packages/browseros/tools/bros/internal/native/release/download.go @@ -0,0 +1,170 @@ +package release + +import ( + "context" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "sort" + "time" +) + +type DownloadOptions struct { + OSFilter string + OutputDir string + HTTPClient *http.Client +} + +type DownloadTarget struct { + Platform string + ArtifactKey string + Filename string + URL string + Path string + Expected int64 +} + +type DownloadResult struct { + DownloadTarget + BytesWritten int64 + Err error +} + +type DownloadSummary struct { + Directory string + Results []DownloadResult +} + +func ResolveDownloadDirectory(version, outputDir string) string { + if outputDir != "" { + return filepath.Join(outputDir, version) + } + return filepath.Join(os.TempDir(), "browseros-releases", version) +} + +func ResolveDownloadPlatforms(osFilter string) ([]string, error) { + normalized, err := NormalizeOSFilter(osFilter) + if err != nil { + return nil, err + } + if normalized == "" { + return Platforms, nil + } + return []string{normalized}, nil +} + +func BuildDownloadTargets(metadata map[string]PlatformMetadata, platforms []string, downloadDir string) []DownloadTarget { + targets := make([]DownloadTarget, 0) + + for _, platform := range platforms { + releaseData, ok := metadata[platform] + if !ok { + continue + } + + artifactKeys := make([]string, 0, len(releaseData.Artifacts)) + for key := range releaseData.Artifacts { + artifactKeys = append(artifactKeys, key) + } + sort.Strings(artifactKeys) + + for _, artifactKey := range artifactKeys { + artifact := releaseData.Artifacts[artifactKey] + if artifact.Filename == "" || artifact.URL == "" { + continue + } + + targets = append(targets, DownloadTarget{ + Platform: platform, + ArtifactKey: artifactKey, + Filename: artifact.Filename, + URL: artifact.URL, + Path: filepath.Join(downloadDir, artifact.Filename), + Expected: artifact.Size, + }) + } + } + + return targets +} + +func DownloadArtifacts(ctx context.Context, version string, metadata map[string]PlatformMetadata, opts DownloadOptions) (DownloadSummary, error) { + if len(metadata) == 0 { + return DownloadSummary{}, fmt.Errorf("no release metadata found for version %s", version) + } + + platforms, err := ResolveDownloadPlatforms(opts.OSFilter) + if err != nil { + return DownloadSummary{}, err + } + + downloadDir := ResolveDownloadDirectory(version, opts.OutputDir) + if err := os.MkdirAll(downloadDir, 0o755); err != nil { + return DownloadSummary{}, err + } + + targets := BuildDownloadTargets(metadata, platforms, downloadDir) + client := opts.HTTPClient + if client == nil { + client = &http.Client{Timeout: 30 * time.Second} + } + + results := make([]DownloadResult, 0, len(targets)) + failed := 0 + for _, target := range targets { + written, downloadErr := downloadFile(ctx, client, target.URL, target.Path) + if downloadErr != nil { + failed++ + } + results = append(results, DownloadResult{ + DownloadTarget: target, + BytesWritten: written, + Err: downloadErr, + }) + } + + summary := DownloadSummary{ + Directory: downloadDir, + Results: results, + } + if failed > 0 { + return summary, fmt.Errorf("%d artifact download(s) failed", failed) + } + return summary, nil +} + +func downloadFile(ctx context.Context, client *http.Client, sourceURL, destinationPath string) (int64, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, sourceURL, nil) + if err != nil { + return 0, err + } + + resp, err := client.Do(req) + if err != nil { + return 0, err + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return 0, fmt.Errorf("download %s: unexpected status %d", sourceURL, resp.StatusCode) + } + + if err := os.MkdirAll(filepath.Dir(destinationPath), 0o755); err != nil { + return 0, err + } + + file, err := os.Create(destinationPath) + if err != nil { + return 0, err + } + defer file.Close() + + written, err := io.Copy(file, resp.Body) + if err != nil { + return written, err + } + + return written, nil +} diff --git a/packages/browseros/tools/bros/internal/native/release/exec.go b/packages/browseros/tools/bros/internal/native/release/exec.go new file mode 100644 index 00000000..167a4464 --- /dev/null +++ b/packages/browseros/tools/bros/internal/native/release/exec.go @@ -0,0 +1,20 @@ +package release + +import ( + "bytes" + "context" + "os/exec" +) + +func commandContext(ctx context.Context, name string, args ...string) *exec.Cmd { + return exec.CommandContext(ctx, name, args...) +} + +func runCommand(cmd *exec.Cmd) (string, string, error) { + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + return stdout.String(), stderr.String(), err +} diff --git a/packages/browseros/tools/bros/internal/native/release/github.go b/packages/browseros/tools/bros/internal/native/release/github.go new file mode 100644 index 00000000..b41e14c4 --- /dev/null +++ b/packages/browseros/tools/bros/internal/native/release/github.go @@ -0,0 +1,343 @@ +package release + +import ( + "context" + "errors" + "fmt" + "net/http" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "bros/internal/native/r2" +) + +var ErrGitHubReleaseAlreadyExists = errors.New("github release already exists") + +type CommandRunner interface { + Run(ctx context.Context, name string, args ...string) (stdout string, stderr string, err error) +} + +type ExecCommandRunner struct{} + +func (ExecCommandRunner) Run(ctx context.Context, name string, args ...string) (string, string, error) { + cmd := commandContext(ctx, name, args...) + stdout, stderr, err := runCommand(cmd) + return stdout, stderr, err +} + +type GitHubUploadResult struct { + Platform string + ArtifactKey string + Filename string + Err error +} + +type GitHubReleaseOptions struct { + Version string + Repo string + Draft bool + SkipUpload bool + Title string + Runner CommandRunner + HTTPClient *http.Client +} + +type GitHubReleaseResult struct { + TagVersion string + Repo string + ReleaseURL string + ReleaseExisted bool + UploadResults []GitHubUploadResult + Appcast []AppcastSnippet +} + +func CheckGHCLI(ctx context.Context, runner CommandRunner) error { + if runner == nil { + runner = ExecCommandRunner{} + } + _, stderr, err := runner.Run(ctx, "gh", "--version") + if err != nil { + if strings.TrimSpace(stderr) != "" { + return fmt.Errorf("gh CLI not available: %s", strings.TrimSpace(stderr)) + } + return fmt.Errorf("gh CLI not available: %w", err) + } + return nil +} + +func GetRepoFromGit(ctx context.Context, runner CommandRunner) (string, error) { + if runner == nil { + runner = ExecCommandRunner{} + } + stdout, stderr, err := runner.Run(ctx, "git", "remote", "get-url", "origin") + if err != nil { + if strings.TrimSpace(stderr) != "" { + return "", fmt.Errorf("git remote get-url origin failed: %s", strings.TrimSpace(stderr)) + } + return "", err + } + + repo, ok := ParseGitHubRepo(strings.TrimSpace(stdout)) + if !ok { + return "", fmt.Errorf("could not detect GitHub repo from origin remote") + } + return repo, nil +} + +func ParseGitHubRepo(remoteURL string) (string, bool) { + trimmed := strings.TrimSpace(remoteURL) + if trimmed == "" { + return "", false + } + + if !strings.Contains(trimmed, "github.com") { + return "", false + } + + if strings.HasPrefix(trimmed, "git@") { + parts := strings.SplitN(trimmed, ":", 2) + if len(parts) != 2 { + return "", false + } + return strings.TrimSuffix(parts[1], ".git"), true + } + + parts := strings.Split(trimmed, "/") + if len(parts) < 2 { + return "", false + } + repo := strings.TrimSuffix(strings.Join(parts[len(parts)-2:], "/"), ".git") + if strings.Count(repo, "/") != 1 { + return "", false + } + return repo, true +} + +func NormalizeVersion(version string) string { + parts := strings.Split(version, ".") + if len(parts) >= 3 { + return strings.Join(parts[:3], ".") + } + return version +} + +func GenerateReleaseNotes(version string, metadata map[string]PlatformMetadata) string { + chromiumVersion := "unknown" + for _, platform := range Platforms { + releaseData, ok := metadata[platform] + if !ok { + continue + } + if releaseData.ChromiumVersion != "" { + chromiumVersion = releaseData.ChromiumVersion + break + } + } + + var b strings.Builder + b.WriteString(fmt.Sprintf("## BrowserOS v%s\n\n", version)) + b.WriteString(fmt.Sprintf("Chromium version: %s\n\n", chromiumVersion)) + b.WriteString("### Downloads\n\n") + + for _, platform := range Platforms { + releaseData, ok := metadata[platform] + if !ok { + continue + } + + b.WriteString(fmt.Sprintf("**%s:**\n", PlatformDisplayNames[platform])) + artifactKeys := make([]string, 0, len(releaseData.Artifacts)) + for key := range releaseData.Artifacts { + artifactKeys = append(artifactKeys, key) + } + sort.Strings(artifactKeys) + + for _, key := range artifactKeys { + artifact := releaseData.Artifacts[key] + b.WriteString(fmt.Sprintf("- [%s](%s)\n", artifact.Filename, artifact.URL)) + } + b.WriteString("\n") + } + + return b.String() +} + +func CreateGitHubRelease(ctx context.Context, runner CommandRunner, version, repo, title, notes string, draft bool) (string, error) { + if runner == nil { + runner = ExecCommandRunner{} + } + + args := []string{ + "release", + "create", + "v" + version, + "--repo", repo, + "--title", title, + "--notes", notes, + } + if draft { + args = append(args, "--draft") + } + + stdout, stderr, err := runner.Run(ctx, "gh", args...) + if err != nil { + combined := strings.ToLower(stdout + "\n" + stderr + "\n" + err.Error()) + if strings.Contains(combined, "already exists") { + return "", ErrGitHubReleaseAlreadyExists + } + if strings.TrimSpace(stderr) != "" { + return "", fmt.Errorf("gh release create failed: %s", strings.TrimSpace(stderr)) + } + return "", err + } + + return strings.TrimSpace(stdout), nil +} + +func UploadToGitHubRelease(ctx context.Context, runner CommandRunner, version, repo string, filePath string) error { + if runner == nil { + runner = ExecCommandRunner{} + } + + _, stderr, err := runner.Run(ctx, "gh", "release", "upload", "v"+version, filePath, "--repo", repo) + if err != nil { + if strings.TrimSpace(stderr) != "" { + return fmt.Errorf("gh release upload failed: %s", strings.TrimSpace(stderr)) + } + return err + } + return nil +} + +func DownloadAndUploadArtifacts(ctx context.Context, runner CommandRunner, httpClient *http.Client, version, repo string, metadata map[string]PlatformMetadata, platforms []string) ([]GitHubUploadResult, error) { + if runner == nil { + runner = ExecCommandRunner{} + } + if httpClient == nil { + httpClient = &http.Client{Timeout: 30 * time.Second} + } + if len(platforms) == 0 { + platforms = Platforms + } + + tmpDir, err := os.MkdirTemp("", "bros-release-*") + if err != nil { + return nil, err + } + defer os.RemoveAll(tmpDir) + + results := make([]GitHubUploadResult, 0) + failed := 0 + + for _, platform := range platforms { + releaseData, ok := metadata[platform] + if !ok { + continue + } + + artifactKeys := make([]string, 0, len(releaseData.Artifacts)) + for key := range releaseData.Artifacts { + artifactKeys = append(artifactKeys, key) + } + sort.Strings(artifactKeys) + + for _, key := range artifactKeys { + artifact := releaseData.Artifacts[key] + result := GitHubUploadResult{Platform: platform, ArtifactKey: key, Filename: artifact.Filename} + if artifact.URL == "" || artifact.Filename == "" { + result.Err = fmt.Errorf("artifact missing url or filename") + results = append(results, result) + failed++ + continue + } + + destination := filepath.Join(tmpDir, artifact.Filename) + if _, err := downloadFile(ctx, httpClient, artifact.URL, destination); err != nil { + result.Err = err + results = append(results, result) + failed++ + continue + } + + if err := UploadToGitHubRelease(ctx, runner, version, repo, destination); err != nil { + result.Err = err + failed++ + } + results = append(results, result) + } + } + + if failed > 0 { + return results, fmt.Errorf("%d artifact upload(s) failed", failed) + } + return results, nil +} + +func CreateAndUploadGitHubRelease(ctx context.Context, client *r2.Client, opts GitHubReleaseOptions) (GitHubReleaseResult, error) { + runner := opts.Runner + if runner == nil { + runner = ExecCommandRunner{} + } + + if err := CheckGHCLI(ctx, runner); err != nil { + return GitHubReleaseResult{}, err + } + + repo := strings.TrimSpace(opts.Repo) + if repo == "" { + detectedRepo, err := GetRepoFromGit(ctx, runner) + if err != nil { + return GitHubReleaseResult{}, err + } + repo = detectedRepo + } + + metadata, err := FetchAllReleaseMetadata(ctx, client, opts.Version) + if err != nil { + return GitHubReleaseResult{}, err + } + if len(metadata) == 0 { + return GitHubReleaseResult{}, fmt.Errorf("no release metadata found for version %s", opts.Version) + } + + tagVersion := NormalizeVersion(opts.Version) + releaseTitle := opts.Title + if strings.TrimSpace(releaseTitle) == "" { + releaseTitle = "v" + tagVersion + } + notes := GenerateReleaseNotes(tagVersion, metadata) + + releaseURL, createErr := CreateGitHubRelease(ctx, runner, tagVersion, repo, releaseTitle, notes, opts.Draft) + releaseExisted := false + if createErr != nil { + if errors.Is(createErr, ErrGitHubReleaseAlreadyExists) { + releaseExisted = true + } else { + return GitHubReleaseResult{}, createErr + } + } + + result := GitHubReleaseResult{ + TagVersion: tagVersion, + Repo: repo, + ReleaseURL: releaseURL, + ReleaseExisted: releaseExisted, + } + + if !opts.SkipUpload { + uploadResults, err := DownloadAndUploadArtifacts(ctx, runner, opts.HTTPClient, tagVersion, repo, metadata, nil) + result.UploadResults = uploadResults + if err != nil { + return result, err + } + } + + if macOS, ok := metadata[PlatformMacOS]; ok { + result.Appcast = GenerateAppcastSnippets(tagVersion, macOS) + } + + return result, nil +} diff --git a/packages/browseros/tools/bros/internal/native/release/github_test.go b/packages/browseros/tools/bros/internal/native/release/github_test.go new file mode 100644 index 00000000..022a8721 --- /dev/null +++ b/packages/browseros/tools/bros/internal/native/release/github_test.go @@ -0,0 +1,53 @@ +package release + +import ( + "strings" + "testing" +) + +func TestParseGitHubRepo(t *testing.T) { + cases := []struct { + remote string + repo string + ok bool + }{ + {remote: "git@github.com:browseros/browseros.git", repo: "browseros/browseros", ok: true}, + {remote: "https://github.com/browseros/browseros.git", repo: "browseros/browseros", ok: true}, + {remote: "https://gitlab.com/browseros/browseros.git", ok: false}, + } + + for _, tc := range cases { + repo, ok := ParseGitHubRepo(tc.remote) + if ok != tc.ok || repo != tc.repo { + t.Fatalf("ParseGitHubRepo(%q)=(%q,%v) want (%q,%v)", tc.remote, repo, ok, tc.repo, tc.ok) + } + } +} + +func TestNormalizeVersion(t *testing.T) { + if got := NormalizeVersion("0.31.0.4"); got != "0.31.0" { + t.Fatalf("NormalizeVersion returned %q", got) + } + if got := NormalizeVersion("0.31"); got != "0.31" { + t.Fatalf("NormalizeVersion returned %q", got) + } +} + +func TestGenerateReleaseNotes(t *testing.T) { + metadata := map[string]PlatformMetadata{ + PlatformMacOS: { + ChromiumVersion: "120.0.1.2", + Artifacts: map[string]Artifact{ + "arm64": {Filename: "BrowserOS-arm64.dmg", URL: "https://cdn/arm"}, + }, + }, + } + + notes := GenerateReleaseNotes("0.31.0", metadata) + if !strings.Contains(notes, "Chromium version: 120.0.1.2") { + t.Fatalf("expected chromium version in notes: %s", notes) + } + if !strings.Contains(notes, "[BrowserOS-arm64.dmg](https://cdn/arm)") { + t.Fatalf("expected artifact link in notes: %s", notes) + } +} diff --git a/packages/browseros/tools/bros/internal/native/release/metadata.go b/packages/browseros/tools/bros/internal/native/release/metadata.go new file mode 100644 index 00000000..3156f1f8 --- /dev/null +++ b/packages/browseros/tools/bros/internal/native/release/metadata.go @@ -0,0 +1,80 @@ +package release + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + + "bros/internal/native/r2" +) + +func ListAllVersions(ctx context.Context, client *r2.Client) ([]string, error) { + return client.ListReleaseVersions(ctx) +} + +func FetchAllReleaseMetadata(ctx context.Context, client *r2.Client, version string) (map[string]PlatformMetadata, error) { + metadata := make(map[string]PlatformMetadata) + for _, platform := range Platforms { + releaseData, err := FetchReleaseMetadataForPlatform(ctx, client, version, platform) + if err != nil { + if errors.Is(err, r2.ErrNotFound) { + continue + } + return nil, err + } + metadata[platform] = releaseData + } + return metadata, nil +} + +func FetchReleaseMetadataForPlatform(ctx context.Context, client *r2.Client, version, platform string) (PlatformMetadata, error) { + data, err := client.FetchReleaseJSON(ctx, version, platform) + if err != nil { + return PlatformMetadata{}, err + } + return ParseReleaseJSON(data) +} + +func ParseReleaseJSON(data []byte) (PlatformMetadata, error) { + var release PlatformMetadata + if err := json.Unmarshal(data, &release); err != nil { + return PlatformMetadata{}, fmt.Errorf("parse release.json: %w", err) + } + if release.Artifacts == nil { + release.Artifacts = map[string]Artifact{} + } + return release, nil +} + +func FormatSize(sizeBytes int64) string { + const ( + kb = int64(1024) + mb = kb * 1024 + gb = mb * 1024 + ) + + switch { + case sizeBytes >= gb: + return fmt.Sprintf("%.1f GB", float64(sizeBytes)/float64(gb)) + case sizeBytes >= mb: + return fmt.Sprintf("%.0f MB", float64(sizeBytes)/float64(mb)) + case sizeBytes >= kb: + return fmt.Sprintf("%.0f KB", float64(sizeBytes)/float64(kb)) + default: + return fmt.Sprintf("%d B", sizeBytes) + } +} + +func NormalizeOSFilter(value string) (string, error) { + if strings.TrimSpace(value) == "" { + return "", nil + } + + normalized := OSNameMap[strings.ToLower(strings.TrimSpace(value))] + if normalized == "" { + return "", fmt.Errorf("invalid --os value: %s", value) + } + return normalized, nil +} diff --git a/packages/browseros/tools/bros/internal/native/release/metadata_test.go b/packages/browseros/tools/bros/internal/native/release/metadata_test.go new file mode 100644 index 00000000..8ae346fe --- /dev/null +++ b/packages/browseros/tools/bros/internal/native/release/metadata_test.go @@ -0,0 +1,88 @@ +package release + +import ( + "reflect" + "testing" +) + +func TestParseReleaseJSON(t *testing.T) { + data := []byte(`{"build_date":"2026-01-01T00:00:00Z","chromium_version":"120.0","artifacts":{"x64":{"filename":"BrowserOS.dmg","url":"https://cdn/browseros.dmg","size":123}}}`) + + parsed, err := ParseReleaseJSON(data) + if err != nil { + t.Fatalf("ParseReleaseJSON returned error: %v", err) + } + + artifact, ok := parsed.Artifacts["x64"] + if !ok { + t.Fatal("expected x64 artifact") + } + if artifact.Filename != "BrowserOS.dmg" { + t.Fatalf("unexpected filename: %q", artifact.Filename) + } +} + +func TestFormatSize(t *testing.T) { + cases := []struct { + in int64 + want string + }{ + {in: 10, want: "10 B"}, + {in: 1024, want: "1 KB"}, + {in: 1024 * 1024, want: "1 MB"}, + {in: 2 * 1024 * 1024 * 1024, want: "2.0 GB"}, + } + + for _, tc := range cases { + if got := FormatSize(tc.in); got != tc.want { + t.Fatalf("FormatSize(%d)=%q want %q", tc.in, got, tc.want) + } + } +} + +func TestNormalizeOSFilter(t *testing.T) { + cases := map[string]string{ + "": "", + "macos": PlatformMacOS, + "mac": PlatformMacOS, + "windows": PlatformWin, + "win": PlatformWin, + "linux": PlatformLinux, + } + + for in, want := range cases { + got, err := NormalizeOSFilter(in) + if err != nil { + t.Fatalf("NormalizeOSFilter(%q) error: %v", in, err) + } + if got != want { + t.Fatalf("NormalizeOSFilter(%q)=%q want %q", in, got, want) + } + } + + if _, err := NormalizeOSFilter("bsd"); err == nil { + t.Fatal("expected invalid os error") + } +} + +func TestBuildDownloadTargets(t *testing.T) { + metadata := map[string]PlatformMetadata{ + PlatformMacOS: { + Artifacts: map[string]Artifact{ + "arm64": {Filename: "BrowserOS-arm64.dmg", URL: "https://cdn/a"}, + "x64": {Filename: "BrowserOS-x64.dmg", URL: "https://cdn/b"}, + }, + }, + } + + targets := BuildDownloadTargets(metadata, []string{PlatformMacOS}, "/tmp/release") + if len(targets) != 2 { + t.Fatalf("expected 2 targets, got %d", len(targets)) + } + + filenames := []string{targets[0].Filename, targets[1].Filename} + want := []string{"BrowserOS-arm64.dmg", "BrowserOS-x64.dmg"} + if !reflect.DeepEqual(filenames, want) { + t.Fatalf("unexpected filenames: got %v want %v", filenames, want) + } +} diff --git a/packages/browseros/tools/bros/internal/native/release/publish.go b/packages/browseros/tools/bros/internal/native/release/publish.go new file mode 100644 index 00000000..2ae4b0ab --- /dev/null +++ b/packages/browseros/tools/bros/internal/native/release/publish.go @@ -0,0 +1,82 @@ +package release + +import ( + "context" + "fmt" + "sort" + + "bros/internal/native/r2" +) + +type PublishOperation struct { + Platform string + ArtifactKey string + Filename string + SourceKey string + DestinationKey string +} + +type PublishResult struct { + PublishOperation + Err error +} + +func BuildPublishOperations(version string, metadata map[string]PlatformMetadata, platforms []string) []PublishOperation { + resolvedPlatforms := platforms + if len(resolvedPlatforms) == 0 { + resolvedPlatforms = Platforms + } + + operations := make([]PublishOperation, 0) + for _, platform := range resolvedPlatforms { + releaseData, ok := metadata[platform] + if !ok { + continue + } + + mapping, ok := DownloadPathMapping[platform] + if !ok { + continue + } + + artifactKeys := make([]string, 0, len(releaseData.Artifacts)) + for key := range releaseData.Artifacts { + artifactKeys = append(artifactKeys, key) + } + sort.Strings(artifactKeys) + + for _, artifactKey := range artifactKeys { + destination, mapped := mapping[artifactKey] + if !mapped { + continue + } + artifact := releaseData.Artifacts[artifactKey] + operations = append(operations, PublishOperation{ + Platform: platform, + ArtifactKey: artifactKey, + Filename: artifact.Filename, + SourceKey: fmt.Sprintf("releases/%s/%s/%s", version, platform, artifact.Filename), + DestinationKey: destination, + }) + } + } + + return operations +} + +func PublishArtifacts(ctx context.Context, client *r2.Client, version string, metadata map[string]PlatformMetadata, platforms []string) ([]PublishResult, error) { + if len(metadata) == 0 { + return nil, fmt.Errorf("no release metadata found for version %s", version) + } + + ops := BuildPublishOperations(version, metadata, platforms) + results := make([]PublishResult, 0, len(ops)) + for _, op := range ops { + err := client.CopyObject(ctx, op.SourceKey, op.DestinationKey) + results = append(results, PublishResult{ + PublishOperation: op, + Err: err, + }) + } + return results, nil +} diff --git a/packages/browseros/tools/bros/internal/native/release/publish_test.go b/packages/browseros/tools/bros/internal/native/release/publish_test.go new file mode 100644 index 00000000..fe839193 --- /dev/null +++ b/packages/browseros/tools/bros/internal/native/release/publish_test.go @@ -0,0 +1,27 @@ +package release + +import "testing" + +func TestBuildPublishOperations(t *testing.T) { + metadata := map[string]PlatformMetadata{ + PlatformMacOS: { + Artifacts: map[string]Artifact{ + "arm64": {Filename: "BrowserOS-arm64.dmg"}, + "universal": {Filename: "BrowserOS.dmg"}, + "extra": {Filename: "ignore.zip"}, + }, + }, + } + + ops := BuildPublishOperations("0.31.0", metadata, nil) + if len(ops) != 2 { + t.Fatalf("expected 2 publish operations, got %d", len(ops)) + } + + if ops[0].SourceKey != "releases/0.31.0/macos/BrowserOS-arm64.dmg" { + t.Fatalf("unexpected first source key: %q", ops[0].SourceKey) + } + if ops[0].DestinationKey != "download/BrowserOS-arm64.dmg" { + t.Fatalf("unexpected first destination key: %q", ops[0].DestinationKey) + } +} diff --git a/packages/browseros/tools/bros/internal/native/release/run.go b/packages/browseros/tools/bros/internal/native/release/run.go new file mode 100644 index 00000000..033b3891 --- /dev/null +++ b/packages/browseros/tools/bros/internal/native/release/run.go @@ -0,0 +1,157 @@ +package release + +import ( + "context" + "fmt" + + "bros/internal/native/r2" +) + +type Options struct { + Version string + List bool + Appcast bool + Publish bool + Download bool + OSFilter string + Output string + ShowModules bool +} + +type ModuleInfo struct { + Name string + Description string +} + +var AvailableModules = []ModuleInfo{ + {Name: "list", Description: "List release artifacts from R2"}, + {Name: "appcast", Description: "Generate Sparkle appcast XML snippets"}, + {Name: "github", Description: "Create GitHub release from R2 artifacts"}, + {Name: "publish", Description: "Publish versioned artifacts to latest download URLs"}, + {Name: "download", Description: "Download release artifacts from CDN"}, +} + +type RunResult struct { + Modules []ModuleInfo + Versions []string + Metadata map[string]PlatformMetadata + Appcast []AppcastSnippet + PublishResults []PublishResult + Download DownloadSummary +} + +func ValidateOptions(opts Options) error { + if opts.ShowModules { + return nil + } + + hasFlags := opts.List || opts.Appcast || opts.Publish || opts.Download + if !hasFlags { + return fmt.Errorf("specify a flag (--list, --appcast, --publish, --download)") + } + + requiresVersion := opts.Appcast || opts.Publish || opts.Download + if requiresVersion && opts.Version == "" { + return fmt.Errorf("--version is required for this operation") + } + + if _, err := NormalizeOSFilter(opts.OSFilter); err != nil { + return err + } + + return nil +} + +func Run(ctx context.Context, client *r2.Client, opts Options) (RunResult, error) { + if err := ValidateOptions(opts); err != nil { + return RunResult{}, err + } + + result := RunResult{} + if opts.ShowModules { + result.Modules = append(result.Modules, AvailableModules...) + return result, nil + } + + var ( + metadata map[string]PlatformMetadata + hasFetched bool + ) + fetchMetadata := func() (map[string]PlatformMetadata, error) { + if hasFetched { + return metadata, nil + } + fetched, err := FetchAllReleaseMetadata(ctx, client, opts.Version) + if err != nil { + return nil, err + } + metadata = fetched + hasFetched = true + return metadata, nil + } + + if opts.List { + if opts.Version == "" { + versions, err := ListAllVersions(ctx, client) + if err != nil { + return RunResult{}, err + } + result.Versions = versions + } else { + fetched, err := fetchMetadata() + if err != nil { + return RunResult{}, err + } + result.Metadata = fetched + } + } + + if opts.Appcast { + fetched, err := fetchMetadata() + if err != nil { + return RunResult{}, err + } + result.Metadata = fetched + + if macOS, ok := fetched[PlatformMacOS]; ok { + result.Appcast = GenerateAppcastSnippets(opts.Version, macOS) + } + } + + if opts.Publish { + fetched, err := fetchMetadata() + if err != nil { + return RunResult{}, err + } + result.Metadata = fetched + + if len(fetched) > 0 { + publishResults, err := PublishArtifacts(ctx, client, opts.Version, fetched, nil) + if err != nil { + return RunResult{}, err + } + result.PublishResults = publishResults + } + } + + if opts.Download { + fetched, err := fetchMetadata() + if err != nil { + return RunResult{}, err + } + result.Metadata = fetched + + if len(fetched) > 0 { + summary, err := DownloadArtifacts(ctx, opts.Version, fetched, DownloadOptions{ + OSFilter: opts.OSFilter, + OutputDir: opts.Output, + }) + if err != nil { + return RunResult{}, err + } + result.Download = summary + } + } + + return result, nil +} diff --git a/packages/browseros/tools/bros/internal/native/release/run_test.go b/packages/browseros/tools/bros/internal/native/release/run_test.go new file mode 100644 index 00000000..d5aae133 --- /dev/null +++ b/packages/browseros/tools/bros/internal/native/release/run_test.go @@ -0,0 +1,28 @@ +package release + +import "testing" + +func TestValidateOptions(t *testing.T) { + cases := []struct { + name string + opts Options + wantErr bool + }{ + {name: "show modules only", opts: Options{ShowModules: true}, wantErr: false}, + {name: "no flags", opts: Options{}, wantErr: true}, + {name: "list without version", opts: Options{List: true}, wantErr: false}, + {name: "appcast requires version", opts: Options{Appcast: true}, wantErr: true}, + {name: "publish with version", opts: Options{Publish: true, Version: "0.31.0"}, wantErr: false}, + {name: "download invalid os", opts: Options{Download: true, Version: "0.31.0", OSFilter: "bsd"}, wantErr: true}, + } + + for _, tc := range cases { + err := ValidateOptions(tc.opts) + if tc.wantErr && err == nil { + t.Fatalf("%s: expected error", tc.name) + } + if !tc.wantErr && err != nil { + t.Fatalf("%s: unexpected error: %v", tc.name, err) + } + } +} diff --git a/packages/browseros/tools/bros/internal/native/release/types.go b/packages/browseros/tools/bros/internal/native/release/types.go new file mode 100644 index 00000000..d1bd6328 --- /dev/null +++ b/packages/browseros/tools/bros/internal/native/release/types.go @@ -0,0 +1,53 @@ +package release + +const ( + PlatformMacOS = "macos" + PlatformWin = "win" + PlatformLinux = "linux" +) + +var Platforms = []string{PlatformMacOS, PlatformWin, PlatformLinux} + +var PlatformDisplayNames = map[string]string{ + PlatformMacOS: "macOS", + PlatformWin: "Windows", + PlatformLinux: "Linux", +} + +var DownloadPathMapping = map[string]map[string]string{ + PlatformMacOS: { + "arm64": "download/BrowserOS-arm64.dmg", + "x64": "download/BrowserOS-x86_64.dmg", + "universal": "download/BrowserOS.dmg", + }, + PlatformWin: { + "x64_installer": "download/BrowserOS_installer.exe", + }, + PlatformLinux: { + "x64_appimage": "download/BrowserOS.AppImage", + "x64_deb": "download/browseros.deb", + }, +} + +var OSNameMap = map[string]string{ + "macos": PlatformMacOS, + "mac": PlatformMacOS, + "windows": PlatformWin, + "win": PlatformWin, + "linux": PlatformLinux, +} + +type Artifact struct { + Filename string `json:"filename"` + URL string `json:"url"` + Size int64 `json:"size"` + SparkleSignature string `json:"sparkle_signature,omitempty"` + SparkleLength int64 `json:"sparkle_length,omitempty"` +} + +type PlatformMetadata struct { + BuildDate string `json:"build_date"` + ChromiumVersion string `json:"chromium_version"` + SparkleVersion string `json:"sparkle_version,omitempty"` + Artifacts map[string]Artifact `json:"artifacts"` +} diff --git a/packages/browseros/tools/bros/main.go b/packages/browseros/tools/bros/main.go index 9acffa95..5e764317 100644 --- a/packages/browseros/tools/bros/main.go +++ b/packages/browseros/tools/bros/main.go @@ -1,10 +1,12 @@ package main import ( + "errors" "fmt" "os" "bros/cmd" + "bros/internal/exitcode" ) var version = "dev" @@ -12,6 +14,14 @@ var version = "dev" func main() { cmd.SetVersion(version) if err := cmd.Execute(); err != nil { + var codedErr *exitcode.Error + if errors.As(err, &codedErr) { + if codedErr.HasMessage() { + fmt.Fprintln(os.Stderr, codedErr.Error()) + } + os.Exit(codedErr.ExitCode()) + } + fmt.Fprintln(os.Stderr, err) os.Exit(2) }