Compare commits

..

1 Commits

Author SHA1 Message Date
Nikhil Sonti
976a78a8f5 fix: windows app-data location fix 2026-04-01 15:12:15 -07:00
13 changed files with 91 additions and 266 deletions

2
.gitattributes vendored
View File

@@ -9,6 +9,4 @@ packages/browseros/chromium_patches/**/*.py linguist-generated
scripts/*.py linguist-generated
# Mark build directories as generated
build/* linguist-generated
# Mark eval/test framework as vendored so it's excluded from language stats
packages/browseros-agent/apps/eval/** linguist-vendored
docs/videos/** filter=lfs diff=lfs merge=lfs -text

View File

@@ -53,6 +53,11 @@ jobs:
fi
- name: Build release artifacts
env:
BROWSEROS_CONFIG_URL: ${{ secrets.BROWSEROS_CONFIG_URL }}
CODEGEN_SERVICE_URL: ${{ secrets.CODEGEN_SERVICE_URL }}
POSTHOG_API_KEY: ${{ secrets.POSTHOG_API_KEY }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
run: bun run build:server:ci
- name: Verify release artifacts

View File

@@ -192,7 +192,7 @@ We'd love your help making BrowserOS better! See our [Contributing Guide](CONTRI
BrowserOS is open source under the [AGPL-3.0 license](LICENSE).
Copyright © 2026 Felafax, Inc.
Copyright © 2025 Felafax, Inc.
## Stargazers

View File

@@ -18,6 +18,7 @@ import {
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
// Derive the build target from the current platform so the test is portable
function getNativeTarget(): { id: string; ext: string } {
const os =
process.platform === 'darwin'
@@ -29,22 +30,7 @@ function getNativeTarget(): { id: string; ext: string } {
return { id: `${os}-${cpu}`, ext: process.platform === 'win32' ? '.exe' : '' }
}
const REQUIRED_INLINE_ENV_KEYS = [
'BROWSEROS_CONFIG_URL',
'CODEGEN_SERVICE_URL',
'POSTHOG_API_KEY',
'SENTRY_DSN',
] as const
const R2_ENV_KEYS = [
'R2_ACCOUNT_ID',
'R2_ACCESS_KEY_ID',
'R2_SECRET_ACCESS_KEY',
'R2_BUCKET',
] as const
const PROD_SECRET_KEYS = [...REQUIRED_INLINE_ENV_KEYS, ...R2_ENV_KEYS]
// Stub values so the build config validation passes without real secrets
const INLINE_ENV_STUBS: Record<string, string> = {
BROWSEROS_CONFIG_URL: 'https://stub.test/config',
CODEGEN_SERVICE_URL: 'https://stub.test/codegen',
@@ -67,10 +53,6 @@ describe('server build', () => {
rootDir,
'apps/server/.env.production.example',
)
const originalProdEnv = existsSync(prodEnvPath)
? readFileSync(prodEnvPath, 'utf-8')
: null
const prodEnvTemplate = readFileSync(prodEnvTemplatePath, 'utf-8')
const buildScript = resolve(rootDir, 'scripts/build/server.ts')
const target = getNativeTarget()
const binaryPath = resolve(
@@ -81,16 +63,23 @@ describe('server build', () => {
rootDir,
`dist/prod/server/browseros-server-resources-${target.id}.zip`,
)
const createdProdEnv = !existsSync(prodEnvPath)
// Empty manifest so the build skips R2 resource downloads
const tempDir = mkdtempSync(join(tmpdir(), 'browseros-build-test-'))
const emptyManifestPath = join(tempDir, 'empty-manifest.json')
writeFileSync(emptyManifestPath, JSON.stringify({ resources: [] }))
if (createdProdEnv) {
writeFileSync(prodEnvPath, readFileSync(prodEnvTemplatePath, 'utf-8'))
}
function buildEnv(
extraEnv: Record<string, string>,
omitKeys: readonly string[] = [],
omitKeys: string[] = [],
): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = {
...process.env,
...INLINE_ENV_STUBS,
...extraEnv,
}
for (const key of omitKeys) {
@@ -99,21 +88,14 @@ describe('server build', () => {
return env
}
function resetProdEnvToTemplate(): void {
writeFileSync(prodEnvPath, prodEnvTemplate)
}
afterAll(() => {
rmSync(tempDir, { recursive: true, force: true })
if (originalProdEnv === null) {
if (createdProdEnv) {
rmSync(prodEnvPath, { force: true })
return
}
writeFileSync(prodEnvPath, originalProdEnv)
})
it('compiles and --version outputs correct version', async () => {
resetProdEnvToTemplate()
const pkg = await Bun.file(serverPkgPath).json()
const expectedVersion: string = pkg.version
@@ -129,7 +111,7 @@ describe('server build', () => {
cwd: rootDir,
stdout: 'pipe',
stderr: 'pipe',
env: buildEnv({ ...INLINE_ENV_STUBS, ...R2_ENV_STUBS }),
env: buildEnv(R2_ENV_STUBS),
},
)
const buildExit = await build.exited
@@ -156,45 +138,33 @@ describe('server build', () => {
assert.strictEqual(versionOutput.trim(), expectedVersion)
}, 300_000)
it('keeps compile-only on the strict production validation path', async () => {
resetProdEnvToTemplate()
const build = Bun.spawn(
['bun', buildScript, `--target=${target.id}`, '--compile-only'],
{
cwd: rootDir,
stdout: 'pipe',
stderr: 'pipe',
env: buildEnv({}, PROD_SECRET_KEYS),
},
)
const buildExit = await build.exited
const stderr = await new Response(build.stderr).text()
assert.notStrictEqual(buildExit, 0, 'Compile-only build should fail')
assert.match(stderr, /Production build requires variables:/)
assert.match(stderr, /CODEGEN_SERVICE_URL/)
assert.match(stderr, /POSTHOG_API_KEY/)
assert.match(stderr, /SENTRY_DSN/)
}, 300_000)
it('archives CI builds without R2 config or production env secrets', async () => {
resetProdEnvToTemplate()
it('archives compile-only builds without R2 config', async () => {
rmSync(zipPath, { force: true })
const build = Bun.spawn(
['bun', buildScript, `--target=${target.id}`, '--ci'],
[
'bun',
buildScript,
`--target=${target.id}`,
'--compile-only',
'--archive-compiled',
],
{
cwd: rootDir,
stdout: 'pipe',
stderr: 'pipe',
env: buildEnv({}, PROD_SECRET_KEYS),
env: buildEnv({}, [
'R2_ACCOUNT_ID',
'R2_ACCESS_KEY_ID',
'R2_SECRET_ACCESS_KEY',
'R2_BUCKET',
]),
},
)
const buildExit = await build.exited
if (buildExit !== 0) {
const stderr = await new Response(build.stderr).text()
assert.fail(`CI build failed (exit ${buildExit}):\n${stderr}`)
assert.fail(`Compile-only archive failed (exit ${buildExit}):\n${stderr}`)
}
assert.ok(existsSync(zipPath), `Expected archive at ${zipPath}`)

View File

@@ -19,7 +19,7 @@
"start:agent": "bun run --filter @browseros/agent dev",
"build": "bun run build:server && bun run build:agent",
"build:server": "FORCE_COLOR=1 bun scripts/build/server.ts --target=all",
"build:server:ci": "FORCE_COLOR=1 bun scripts/build/server.ts --target=all --ci",
"build:server:ci": "FORCE_COLOR=1 bun scripts/build/server.ts --target=all --compile-only --archive-compiled",
"build:server:test": "FORCE_COLOR=1 bun scripts/build/server.ts --target=darwin-arm64 --no-upload",
"upload:cli-installers": "bun scripts/build/cli.ts",
"start:server:test": "bun run build:server:test && set -a && . apps/server/.env.development && set +a && dist/prod/server/.tmp/binaries/browseros-server-darwin-arm64",

View File

@@ -23,11 +23,11 @@ export function parseBuildArgs(argv: string[]): BuildArgs {
.option('--no-upload', 'Skip zip upload to R2')
.option(
'--compile-only',
'Compile binaries only (skip artifact packaging, R2 staging, and upload)',
'Compile binaries only (skip R2 staging and upload)',
)
.option(
'--ci',
'Build local release zip artifacts for CI without R2 and without requiring production env secrets',
'--archive-compiled',
'Archive compile-only binaries into local zip files without R2 resources',
)
program.parse(argv, { from: 'user' })
const options = program.opts<{
@@ -35,23 +35,20 @@ export function parseBuildArgs(argv: string[]): BuildArgs {
manifest: string
upload: boolean
compileOnly: boolean
ci: boolean
archiveCompiled: boolean
}>()
const compileOnly = options.compileOnly ?? false
const ci = options.ci ?? false
if (ci && compileOnly) {
throw new Error('--ci cannot be combined with --compile-only')
}
if (ci && options.upload) {
throw new Error('--ci cannot be combined with --upload')
const archiveCompiled = options.archiveCompiled ?? false
if (archiveCompiled && !compileOnly) {
throw new Error('--archive-compiled requires --compile-only')
}
return {
targets: resolveTargets(options.target),
manifestPath: options.manifest,
upload: ci || compileOnly ? false : (options.upload ?? true),
upload: compileOnly ? false : (options.upload ?? true),
compileOnly,
ci,
archiveCompiled,
}
}

View File

@@ -1,7 +1,6 @@
import { mkdirSync, rmSync } from 'node:fs'
import { join } from 'node:path'
import { log } from '../log'
import { wasmBinaryPlugin } from '../plugins/wasm-binary'
import { runCommand } from './command'
import type { BuildTarget, CompiledServerBinary } from './types'
@@ -53,7 +52,6 @@ async function bundleServer(
async function compileTarget(
target: BuildTarget,
env: NodeJS.ProcessEnv,
ci: boolean,
): Promise<string> {
const binaryPath = compiledBinaryPath(target)
const args = [
@@ -68,15 +66,11 @@ async function compileTarget(
await runCommand('bun', args, env)
if (target.os === 'windows') {
if (ci) {
log.warn('Skipping Windows exe metadata patching in CI mode')
} else {
await runCommand(
'bun',
['scripts/patch-windows-exe.ts', binaryPath],
process.env,
)
}
await runCommand(
'bun',
['scripts/patch-windows-exe.ts', binaryPath],
process.env,
)
}
return binaryPath
@@ -87,16 +81,14 @@ export async function compileServerBinaries(
envVars: Record<string, string>,
processEnv: NodeJS.ProcessEnv,
version: string,
options?: { ci?: boolean },
): Promise<CompiledServerBinary[]> {
const ci = options?.ci ?? false
rmSync(TMP_ROOT, { recursive: true, force: true })
mkdirSync(BINARIES_DIR, { recursive: true })
await bundleServer(envVars, version)
const compiled: CompiledServerBinary[] = []
for (const target of targets) {
const binaryPath = await compileTarget(target, processEnv, ci)
const binaryPath = await compileTarget(target, processEnv)
compiled.push({ target, binaryPath })
}

View File

@@ -76,7 +76,6 @@ function validateProductionEnv(envVars: Record<string, string>): void {
export interface LoadBuildConfigOptions {
compileOnly?: boolean
ci?: boolean
}
export function loadBuildConfig(
@@ -85,9 +84,7 @@ export function loadBuildConfig(
): BuildConfig {
const fileEnv = loadProdEnv(rootDir)
const envVars = buildInlineEnv(fileEnv)
if (!options.ci) {
validateProductionEnv(envVars)
}
validateProductionEnv(envVars)
const processEnv: NodeJS.ProcessEnv = {
PATH: process.env.PATH ?? '',
@@ -95,7 +92,7 @@ export function loadBuildConfig(
...process.env,
}
if (options.compileOnly || options.ci) {
if (options.compileOnly) {
return { version: readServerVersion(rootDir), envVars, processEnv }
}

View File

@@ -10,9 +10,12 @@ import { getTargetRules, loadManifest } from './manifest'
import { createR2Client } from './r2'
import { stageCompiledArtifact, stageTargetArtifact } from './stage'
function buildModeLabel(argv: { compileOnly: boolean; ci: boolean }): string {
if (argv.ci) {
return 'ci'
function buildModeLabel(argv: {
compileOnly: boolean
archiveCompiled: boolean
}): string {
if (argv.compileOnly && argv.archiveCompiled) {
return 'compile-only+archive'
}
return argv.compileOnly ? 'compile-only' : 'full'
}
@@ -25,7 +28,6 @@ export async function runProdResourceBuild(argv: string[]): Promise<void> {
const buildConfig = loadBuildConfig(rootDir, {
compileOnly: args.compileOnly,
ci: args.ci,
})
log.header(`Building BrowserOS server artifacts v${buildConfig.version}`)
@@ -37,34 +39,33 @@ export async function runProdResourceBuild(argv: string[]): Promise<void> {
buildConfig.envVars,
buildConfig.processEnv,
buildConfig.version,
{ ci: args.ci },
)
if (args.ci) {
const distRoot = getDistProdRoot()
const localArtifacts = []
for (const binary of compiled) {
log.step(`Packaging ${binary.target.name}`)
const staged = await stageCompiledArtifact(
distRoot,
binary.binaryPath,
binary.target,
buildConfig.version,
)
localArtifacts.push(staged)
log.success(`Packaged ${binary.target.id}`)
}
const archiveResults = await archiveArtifacts(localArtifacts)
log.done('CI build completed')
for (const result of archiveResults) {
log.info(`${result.targetId}: ${result.zipPath}`)
}
return
}
if (args.compileOnly) {
if (args.archiveCompiled) {
const distRoot = getDistProdRoot()
const localArtifacts = []
for (const binary of compiled) {
log.step(`Packaging ${binary.target.name}`)
const staged = await stageCompiledArtifact(
distRoot,
binary.binaryPath,
binary.target,
buildConfig.version,
)
localArtifacts.push(staged)
log.success(`Packaged ${binary.target.id}`)
}
const archiveResults = await archiveArtifacts(localArtifacts)
log.done('Compile-only archive build completed')
for (const result of archiveResults) {
log.info(`${result.targetId}: ${result.zipPath}`)
}
return
}
log.done('Compile-only build completed')
for (const binary of compiled) {
log.info(`${binary.target.id}: ${binary.binaryPath}`)

View File

@@ -22,7 +22,7 @@ export interface BuildArgs {
manifestPath: string
upload: boolean
compileOnly: boolean
ci: boolean
archiveCompiled: boolean
}
export interface R2Config {

View File

@@ -1,13 +1,8 @@
diff --git a/chrome/browser/devtools/protocol/browser_handler.cc b/chrome/browser/devtools/protocol/browser_handler.cc
index 30bd52d09c3fc..dd9ef4e3b7cbb 100644
index 30bd52d09c3fc..33c7d6d8455fc 100644
--- a/chrome/browser/devtools/protocol/browser_handler.cc
+++ b/chrome/browser/devtools/protocol/browser_handler.cc
@@ -4,23 +4,37 @@
#include "chrome/browser/devtools/protocol/browser_handler.h"
+#include <algorithm>
#include <set>
@@ -8,19 +8,32 @@
#include <vector>
#include "base/functional/bind.h"
@@ -40,7 +35,7 @@ index 30bd52d09c3fc..dd9ef4e3b7cbb 100644
#include "content/public/browser/browser_task_traits.h"
#include "content/public/browser/browser_thread.h"
#include "content/public/browser/devtools_agent_host.h"
@@ -30,10 +44,21 @@
@@ -30,10 +43,21 @@
#include "ui/gfx/image/image.h"
#include "ui/gfx/image/image_png_rep.h"
@@ -62,7 +57,7 @@ index 30bd52d09c3fc..dd9ef4e3b7cbb 100644
BrowserWindow* GetBrowserWindow(int window_id) {
BrowserWindow* result = nullptr;
ForEachCurrentBrowserWindowInterfaceOrderedByActivation(
@@ -72,17 +97,419 @@ std::unique_ptr<protocol::Browser::Bounds> GetBrowserWindowBounds(
@@ -72,17 +96,411 @@ std::unique_ptr<protocol::Browser::Bounds> GetBrowserWindowBounds(
.Build();
}
@@ -442,14 +437,6 @@ index 30bd52d09c3fc..dd9ef4e3b7cbb 100644
+ out_indices->push_back(found_index);
+ }
+
+ if (!(*out_bwi)->GetTabStripModel()->SupportsTabGroups()) {
+ return Response::ServerError("Tab grouping not supported for this window");
+ }
+
+ std::ranges::sort(*out_indices);
+ out_indices->erase(std::ranges::unique(*out_indices).begin(),
+ out_indices->end());
+
+ return Response::Success();
+}
+
@@ -484,7 +471,7 @@ index 30bd52d09c3fc..dd9ef4e3b7cbb 100644
Response BrowserHandler::GetWindowForTarget(
std::optional<std::string> target_id,
@@ -120,6 +547,65 @@ Response BrowserHandler::GetWindowForTarget(
@@ -120,6 +538,65 @@ Response BrowserHandler::GetWindowForTarget(
return Response::Success();
}
@@ -550,7 +537,7 @@ index 30bd52d09c3fc..dd9ef4e3b7cbb 100644
Response BrowserHandler::GetWindowBounds(
int window_id,
std::unique_ptr<protocol::Browser::Bounds>* out_bounds) {
@@ -297,3 +783,909 @@ protocol::Response BrowserHandler::AddPrivacySandboxEnrollmentOverride(
@@ -297,3 +774,910 @@ protocol::Response BrowserHandler::AddPrivacySandboxEnrollmentOverride(
net::SchemefulSite(url_to_add));
return Response::Success();
}
@@ -1460,3 +1447,4 @@ index 30bd52d09c3fc..dd9ef4e3b7cbb 100644
+bool BrowserHandler::IsHiddenWindow(int window_id) const {
+ return hidden_window_ids_.contains(window_id);
+}
+

View File

@@ -1,123 +0,0 @@
diff --git a/chrome/browser/devtools/protocol/devtools_protocol_browsertest.cc b/chrome/browser/devtools/protocol/devtools_protocol_browsertest.cc
index e57b0883b725f..58bfa8d8f5412 100644
--- a/chrome/browser/devtools/protocol/devtools_protocol_browsertest.cc
+++ b/chrome/browser/devtools/protocol/devtools_protocol_browsertest.cc
@@ -20,6 +20,7 @@
#include "base/test/test_switches.h"
#include "base/test/values_test_util.h"
#include "base/threading/thread_restrictions.h"
+#include "base/time/time.h"
#include "base/values.h"
#include "build/build_config.h"
#include "chrome/browser/apps/app_service/app_service_proxy.h"
@@ -30,6 +31,7 @@
#include "chrome/browser/data_saver/data_saver.h"
#include "chrome/browser/devtools/devtools_window.h"
#include "chrome/browser/devtools/protocol/devtools_protocol_test_support.h"
+#include "chrome/browser/history/history_service_factory.h"
#include "chrome/browser/preloading/preloading_prefs.h"
#include "chrome/browser/privacy_sandbox/privacy_sandbox_attestations/privacy_sandbox_attestations_mixin.h"
#include "chrome/browser/profiles/profile.h"
@@ -43,6 +45,8 @@
#include "components/content_settings/core/browser/cookie_settings.h"
#include "components/content_settings/core/common/pref_names.h"
#include "components/custom_handlers/protocol_handler_registry.h"
+#include "components/history/core/browser/history_service.h"
+#include "components/history/core/test/history_service_test_util.h"
#include "components/infobars/content/content_infobar_manager.h"
#include "components/infobars/core/infobar.h"
#include "components/infobars/core/infobar_delegate.h"
@@ -2202,6 +2206,93 @@ IN_PROC_BROWSER_TEST_F(DevToolsProtocolTest,
SendCommandSync("Target.getTargets");
EXPECT_EQ(2u, result()->FindList("targetInfos")->size());
}
+
+IN_PROC_BROWSER_TEST_F(DevToolsProtocolTest,
+ CreateTabGroupAcceptsUnsortedTabIds) {
+ AttachToBrowserTarget();
+
+ ASSERT_EQ(1, browser()->tab_strip_model()->count());
+
+ base::DictValue params;
+ params.Set("url", "about:blank");
+ params.Set("background", true);
+ ASSERT_TRUE(SendCommandSync("Browser.createTab", params.Clone()));
+ ASSERT_TRUE(SendCommandSync("Browser.createTab", std::move(params)));
+
+ const base::DictValue* tabs_result = SendCommandSync("Browser.getTabs");
+ ASSERT_TRUE(tabs_result);
+ const base::ListValue* tabs = tabs_result->FindList("tabs");
+ ASSERT_TRUE(tabs);
+ ASSERT_EQ(3u, tabs->size());
+
+ std::vector<int> tab_ids;
+ tab_ids.reserve(tabs->size());
+ for (const auto& tab : *tabs) {
+ tab_ids.push_back(*tab.GetDict().FindInt("tabId"));
+ }
+
+ base::ListValue unsorted_tab_ids;
+ unsorted_tab_ids.Append(tab_ids[2]);
+ unsorted_tab_ids.Append(tab_ids[0]);
+
+ base::DictValue create_group_params;
+ create_group_params.Set("tabIds", std::move(unsorted_tab_ids));
+ create_group_params.Set("title", "Unsorted");
+
+ const base::DictValue* create_group_result =
+ SendCommandSync("Browser.createTabGroup", std::move(create_group_params));
+ ASSERT_TRUE(create_group_result);
+ ASSERT_FALSE(error());
+
+ const base::DictValue* group = create_group_result->FindDict("group");
+ ASSERT_TRUE(group);
+ const base::ListValue* grouped_tab_ids = group->FindList("tabIds");
+ ASSERT_TRUE(grouped_tab_ids);
+ ASSERT_EQ(2u, grouped_tab_ids->size());
+ EXPECT_EQ(tab_ids[0], *grouped_tab_ids->front().GetIfInt());
+ EXPECT_EQ(tab_ids[2], *grouped_tab_ids->back().GetIfInt());
+ EXPECT_EQ("Unsorted", *group->FindString("title"));
+}
+
+IN_PROC_BROWSER_TEST_F(DevToolsProtocolTest, HistorySearchUsesVisitTime) {
+ AttachToBrowserTarget();
+
+ history::HistoryService* history_service =
+ HistoryServiceFactory::GetForProfile(browser()->profile(),
+ ServiceAccessType::EXPLICIT_ACCESS);
+ ui_test_utils::WaitForHistoryToLoad(history_service);
+
+ const GURL url("https://history-timestamp-test.example/path");
+ const base::Time older_visit = base::Time::Now() - base::Days(2);
+ const base::Time newer_visit = base::Time::Now() - base::Hours(1);
+
+ history_service->AddPage(url, older_visit, history::SOURCE_BROWSED);
+ history_service->AddPage(url, newer_visit, history::SOURCE_BROWSED);
+ history::BlockUntilHistoryProcessesPendingRequests(history_service);
+
+ base::DictValue search_params;
+ search_params.Set("query", "");
+ search_params.Set(
+ "startTime",
+ (older_visit - base::Minutes(1)).InMillisecondsFSinceUnixEpoch());
+ search_params.Set(
+ "endTime",
+ (newer_visit - base::Minutes(1)).InMillisecondsFSinceUnixEpoch());
+
+ const base::DictValue* search_result =
+ SendCommandSync("History.search", std::move(search_params));
+ ASSERT_TRUE(search_result);
+ ASSERT_FALSE(error());
+
+ const base::ListValue* entries = search_result->FindList("entries");
+ ASSERT_TRUE(entries);
+ ASSERT_EQ(1u, entries->size());
+
+ const base::DictValue& entry = entries->front().GetDict();
+ EXPECT_EQ(url.spec(), *entry.FindString("url"));
+ EXPECT_EQ(older_visit.InMillisecondsFSinceUnixEpoch(),
+ *entry.FindDouble("lastVisitTime"));
+}
#endif // !BUILDFLAG(IS_ANDROID)
#if !BUILDFLAG(IS_ANDROID)

View File

@@ -1,6 +1,6 @@
diff --git a/chrome/browser/devtools/protocol/history_handler.cc b/chrome/browser/devtools/protocol/history_handler.cc
new file mode 100644
index 0000000000000..4087a679a527f
index 0000000000000..689f6e900a968
--- /dev/null
+++ b/chrome/browser/devtools/protocol/history_handler.cc
@@ -0,0 +1,188 @@
@@ -36,7 +36,7 @@ index 0000000000000..4087a679a527f
+ .SetId(base::NumberToString(result.id()))
+ .SetUrl(result.url().spec())
+ .SetTitle(base::UTF16ToUTF8(result.title()))
+ .SetLastVisitTime(result.visit_time().InMillisecondsFSinceUnixEpoch())
+ .SetLastVisitTime(result.last_visit().InMillisecondsFSinceUnixEpoch())
+ .SetVisitCount(result.visit_count())
+ .SetTypedCount(result.typed_count())
+ .Build();