Compare commits

...

5 Commits

Author SHA1 Message Date
lucaronin
74e50c64bb fix: hide stale getting started vault entries 2026-04-20 01:02:08 +02:00
lucaronin
8b97ef711f feat: refresh welcome screen branding 2026-04-20 00:45:14 +02:00
lucaronin
08855cee92 fix: defer git history during note switching 2026-04-20 00:09:52 +02:00
lucaronin
03042bb49c fix: normalize shipped telemetry secrets 2026-04-19 23:27:57 +02:00
lucaronin
cee4bef179 fix: harden shipped telemetry config 2026-04-19 23:16:34 +02:00
15 changed files with 832 additions and 35 deletions

View File

@@ -127,6 +127,63 @@ jobs:
security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
echo "KEYCHAIN_PATH=$KEYCHAIN_PATH" >> "$GITHUB_ENV"
- name: Validate telemetry env
env:
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
run: |
python3 <<'PY'
import os
import sys
from urllib.parse import urlparse
def normalize(name: str) -> str:
value = os.getenv(name, "").strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
value = value[1:-1].strip()
return value
def normalize_http_like(value: str) -> str:
if "://" in value:
return value
return f"https://{value}"
def is_http_url(value: str) -> bool:
parsed = urlparse(normalize_http_like(value))
return parsed.scheme in {"http", "https"} and bool(parsed.netloc)
values = {
name: normalize(name)
for name in (
"VITE_SENTRY_DSN",
"SENTRY_DSN",
"VITE_POSTHOG_KEY",
"VITE_POSTHOG_HOST",
)
}
errors = []
for name in ("VITE_SENTRY_DSN", "SENTRY_DSN", "VITE_POSTHOG_HOST"):
value = values[name]
if not value:
errors.append(f"{name} must be set for release builds")
elif not is_http_url(value):
errors.append(f"{name} must be a valid http(s) URL")
if not values["VITE_POSTHOG_KEY"]:
errors.append("VITE_POSTHOG_KEY must be set for release builds")
if errors:
print("Telemetry env validation failed:", file=sys.stderr)
for error in errors:
print(f"- {error}", file=sys.stderr)
raise SystemExit(1)
print("Telemetry env validation passed.")
PY
- name: Build Tauri app (with signing + notarization)
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}

View File

@@ -170,6 +170,63 @@ jobs:
security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
echo "KEYCHAIN_PATH=$KEYCHAIN_PATH" >> "$GITHUB_ENV"
- name: Validate telemetry env
env:
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
run: |
python3 <<'PY'
import os
import sys
from urllib.parse import urlparse
def normalize(name: str) -> str:
value = os.getenv(name, "").strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
value = value[1:-1].strip()
return value
def normalize_http_like(value: str) -> str:
if "://" in value:
return value
return f"https://{value}"
def is_http_url(value: str) -> bool:
parsed = urlparse(normalize_http_like(value))
return parsed.scheme in {"http", "https"} and bool(parsed.netloc)
values = {
name: normalize(name)
for name in (
"VITE_SENTRY_DSN",
"SENTRY_DSN",
"VITE_POSTHOG_KEY",
"VITE_POSTHOG_HOST",
)
}
errors = []
for name in ("VITE_SENTRY_DSN", "SENTRY_DSN", "VITE_POSTHOG_HOST"):
value = values[name]
if not value:
errors.append(f"{name} must be set for release builds")
elif not is_http_url(value):
errors.append(f"{name} must be a valid http(s) URL")
if not values["VITE_POSTHOG_KEY"]:
errors.append("VITE_POSTHOG_KEY must be set for release builds")
if errors:
print("Telemetry env validation failed:", file=sys.stderr)
for error in errors:
print(f"- {error}", file=sys.stderr)
raise SystemExit(1)
print("Telemetry env validation passed.")
PY
- name: Build Tauri app (with signing + notarization)
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}

View File

@@ -11,6 +11,39 @@ fn scrub_paths(input: &str) -> String {
re.replace_all(input, "<redacted-path>").to_string()
}
fn normalize_embedded_env(raw: Option<&str>) -> Option<String> {
let value = raw?.trim();
if value.is_empty() {
return None;
}
let unwrapped = match (value.chars().next(), value.chars().last()) {
(Some('"'), Some('"')) | (Some('\''), Some('\'')) if value.len() >= 2 => {
value[1..value.len() - 1].trim()
}
_ => value,
};
if unwrapped.is_empty() {
None
} else {
Some(unwrapped.to_string())
}
}
fn normalize_http_like_value(value: &str) -> String {
if value.contains("://") {
value.to_string()
} else {
format!("https://{value}")
}
}
fn parse_embedded_sentry_dsn(raw: Option<&str>) -> Option<sentry::types::Dsn> {
let normalized = normalize_embedded_env(raw)?;
normalize_http_like_value(&normalized).parse().ok()
}
/// Initialize Sentry if the user has opted in to crash reporting.
/// Returns `true` if Sentry was initialized, `false` if skipped.
pub fn init_sentry_from_settings() -> bool {
@@ -23,14 +56,13 @@ pub fn init_sentry_from_settings() -> bool {
return false;
}
let dsn = option_env!("SENTRY_DSN").unwrap_or_default();
if dsn.is_empty() {
let Some(dsn) = parse_embedded_sentry_dsn(option_env!("SENTRY_DSN")) else {
return false;
}
};
let anonymous_id = settings.anonymous_id.unwrap_or_default();
let guard = sentry::init(sentry::ClientOptions {
dsn: dsn.parse().ok(),
dsn: Some(dsn),
release: Some(env!("CARGO_PKG_VERSION").into()),
send_default_pii: false,
before_send: Some(std::sync::Arc::new(|mut event| {
@@ -86,6 +118,43 @@ mod tests {
assert_eq!(scrub_paths("Normal error message"), "Normal error message");
}
#[test]
fn test_normalize_embedded_env_trims_wrapping_quotes() {
assert_eq!(
normalize_embedded_env(Some(" \"value\" ")).as_deref(),
Some("value")
);
assert_eq!(
normalize_embedded_env(Some(" 'value' ")).as_deref(),
Some("value")
);
}
#[test]
fn test_normalize_embedded_env_drops_blank_values() {
assert_eq!(normalize_embedded_env(Some(" ")), None);
assert_eq!(normalize_embedded_env(None), None);
}
#[test]
fn test_parse_embedded_sentry_dsn_accepts_valid_trimmed_value() {
let parsed =
parse_embedded_sentry_dsn(Some(" \"https://public@example.ingest.sentry.io/1\" "));
assert!(parsed.is_some());
}
#[test]
fn test_parse_embedded_sentry_dsn_accepts_scheme_less_value() {
let parsed = parse_embedded_sentry_dsn(Some("public@example.ingest.sentry.io/1"));
assert!(parsed.is_some());
}
#[test]
fn test_parse_embedded_sentry_dsn_rejects_invalid_value() {
let parsed = parse_embedded_sentry_dsn(Some("not a dsn"));
assert!(parsed.is_none());
}
#[test]
fn test_init_sentry_returns_false_without_dsn() {
// Without SENTRY_DSN env var set at compile time, init should return false

View File

@@ -858,7 +858,8 @@ function App() {
setToastMessage,
})
const gitHistory = useGitHistory(notes.activeTabPath, vault.loadGitHistory)
const shouldLoadGitHistory = !layout.inspectorCollapsed && !showAIChat
const gitHistory = useGitHistory(notes.activeTabPath, vault.loadGitHistory, shouldLoadGitHistory)
const handleCreateType = useCallback((name: string) => {
notes.handleCreateType(name)

View File

@@ -0,0 +1,42 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" fill="none">
<defs>
<linearGradient id="tolaria-tile" x1="18" y1="14" x2="110" y2="114" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#FBFAF7" />
<stop offset="1" stop-color="#EEF4EE" />
</linearGradient>
<linearGradient id="tolaria-mark" x1="34" y1="28" x2="94" y2="98" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#35CBB1" />
<stop offset="1" stop-color="#18A88E" />
</linearGradient>
<filter id="tolaria-tile-shadow" x="2" y="4" width="124" height="122" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feOffset dy="4" />
<feGaussianBlur stdDeviation="3" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0.0627451 0 0 0 0 0.0980392 0 0 0 0 0.164706 0 0 0 0.14 0"
/>
</filter>
<filter id="tolaria-mark-shadow" x="12" y="14" width="104" height="96" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feOffset dy="4" />
<feGaussianBlur stdDeviation="3" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0.0705882 0 0 0 0 0.333333 0 0 0 0 0.278431 0 0 0 0.18 0"
/>
</filter>
</defs>
<g filter="url(#tolaria-tile-shadow)">
<rect x="8" y="8" width="112" height="112" rx="28" fill="url(#tolaria-tile)" />
<rect x="8.75" y="8.75" width="110.5" height="110.5" rx="27.25" stroke="#0F172A" stroke-opacity="0.08" stroke-width="1.5" />
</g>
<g filter="url(#tolaria-mark-shadow)" fill="url(#tolaria-mark)">
<circle cx="64" cy="40" r="18" />
<circle cx="42" cy="56" r="18" />
<circle cx="86" cy="56" r="18" />
<circle cx="48" cy="80" r="24" />
<circle cx="80" cy="80" r="24" />
<rect x="32" y="56" width="64" height="44" rx="18" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -1,6 +1,7 @@
import { render, screen, fireEvent } from '@testing-library/react'
import { beforeEach, describe, it, expect, vi } from 'vitest'
import { WelcomeScreen } from './WelcomeScreen'
import tolariaIcon from '@/assets/tolaria-icon.svg'
const dragRegionMouseDown = vi.fn()
@@ -30,7 +31,14 @@ describe('WelcomeScreen', () => {
it('renders welcome title and subtitle', () => {
render(<WelcomeScreen {...defaultProps} />)
expect(screen.getByText('Welcome to Tolaria')).toBeInTheDocument()
expect(screen.getByText(/Wiki-linked knowledge management/)).toBeInTheDocument()
expect(screen.getByText('Markdown knowledge management for the age of AI')).toBeInTheDocument()
})
it('renders the local Tolaria branding icon', () => {
render(<WelcomeScreen {...defaultProps} />)
const brandIcon = screen.getByAltText('Tolaria icon')
expect(brandIcon).toHaveAttribute('src', tolariaIcon)
})
it('shows all three option buttons', () => {

View File

@@ -3,6 +3,7 @@ import type { ReactNode } from 'react'
import { FolderOpen, Plus, AlertTriangle, Loader2, Rocket } from 'lucide-react'
import { OnboardingShell } from './OnboardingShell'
import { Button } from '@/components/ui/button'
import tolariaIcon from '@/assets/tolaria-icon.svg'
interface WelcomeScreenProps {
mode: 'welcome' | 'vault-missing'
@@ -105,6 +106,12 @@ const ICON_WRAP_STYLE: React.CSSProperties = {
justifyContent: 'center',
}
const BRAND_ICON_STYLE: React.CSSProperties = {
width: 64,
height: 64,
display: 'block',
}
const TITLE_STYLE: React.CSSProperties = {
fontSize: 28,
fontWeight: 700,
@@ -267,10 +274,10 @@ function getWelcomeScreenPresentation(
): WelcomeScreenPresentation {
if (mode === 'welcome') {
return {
heroBackground: 'var(--accent-blue-light, #EBF4FF)',
heroIcon: <span style={{ fontSize: 28, color: 'var(--accent-blue)' }}>&#10022;</span>,
heroBackground: 'transparent',
heroIcon: <img src={tolariaIcon} alt="Tolaria icon" style={BRAND_ICON_STYLE} />,
openFolderLabel: 'Open existing vault',
subtitle: 'Wiki-linked knowledge management for deep thinkers.\nChoose how to get started.',
subtitle: 'Markdown knowledge management for the age of AI',
templateDescription: isOffline
? `Requires internet — clone later. Suggested path: ${defaultVaultPath}`
: 'Download the getting started vault',

View File

@@ -0,0 +1,105 @@
import { renderHook, act } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { GitCommit } from '../types'
import { useGitHistory } from './useGitHistory'
const mockHistory: GitCommit[] = [
{ hash: 'abc', shortHash: 'abc', author: 'luca', date: 1_700_000_000, message: 'Initial commit' },
]
describe('useGitHistory', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('waits briefly before loading note history', async () => {
const loadGitHistory = vi.fn().mockResolvedValue(mockHistory)
const { result } = renderHook(() => useGitHistory('/vault/a.md', loadGitHistory, true))
expect(result.current).toEqual([])
expect(loadGitHistory).not.toHaveBeenCalled()
await act(async () => {
await vi.advanceTimersByTimeAsync(199)
})
expect(loadGitHistory).not.toHaveBeenCalled()
await act(async () => {
await vi.advanceTimersByTimeAsync(1)
})
expect(loadGitHistory).toHaveBeenCalledWith('/vault/a.md')
expect(result.current).toEqual(mockHistory)
})
it('skips loading when history is disabled', () => {
const loadGitHistory = vi.fn().mockResolvedValue(mockHistory)
const { result } = renderHook(() => useGitHistory('/vault/a.md', loadGitHistory, false))
act(() => {
vi.advanceTimersByTime(1_000)
})
expect(loadGitHistory).not.toHaveBeenCalled()
expect(result.current).toEqual([])
})
it('cancels stale pending loads when the active note changes quickly', async () => {
const loadGitHistory = vi.fn((path: string) => Promise.resolve([
{ ...mockHistory[0], hash: path, shortHash: path, message: path },
]))
const { result, rerender } = renderHook(
({ path }) => useGitHistory(path, loadGitHistory, true),
{ initialProps: { path: '/vault/a.md' as string | null } },
)
await act(async () => {
await vi.advanceTimersByTimeAsync(100)
})
rerender({ path: '/vault/b.md' })
await act(async () => {
await vi.advanceTimersByTimeAsync(199)
})
expect(loadGitHistory).not.toHaveBeenCalled()
await act(async () => {
await vi.advanceTimersByTimeAsync(1)
})
expect(loadGitHistory).toHaveBeenCalledTimes(1)
expect(loadGitHistory).toHaveBeenCalledWith('/vault/b.md')
expect(result.current).toEqual([
expect.objectContaining({ hash: '/vault/b.md', message: '/vault/b.md' }),
])
})
it('clears previously loaded history when the inspector is hidden', async () => {
const loadGitHistory = vi.fn().mockResolvedValue(mockHistory)
const { result, rerender } = renderHook(
({ enabled }) => useGitHistory('/vault/a.md', loadGitHistory, enabled),
{ initialProps: { enabled: true } },
)
await act(async () => {
await vi.advanceTimersByTimeAsync(200)
})
expect(result.current).toEqual(mockHistory)
rerender({ enabled: false })
expect(result.current).toEqual([])
})
})

View File

@@ -1,13 +1,43 @@
import { useEffect, useState } from 'react'
import type { GitCommit } from '../types'
export function useGitHistory(activeTabPath: string | null, loadGitHistory: (path: string) => Promise<GitCommit[]>) {
const [gitHistory, setGitHistory] = useState<GitCommit[]>([])
const GIT_HISTORY_LOAD_DELAY_MS = 200
export function useGitHistory(
activeTabPath: string | null,
loadGitHistory: (path: string) => Promise<GitCommit[]>,
enabled = true,
) {
const [loadedHistory, setLoadedHistory] = useState<{
path: string | null
commits: GitCommit[]
}>({
path: null,
commits: [],
})
useEffect(() => {
if (!activeTabPath) return
loadGitHistory(activeTabPath).then(setGitHistory)
}, [activeTabPath, loadGitHistory])
if (!enabled || !activeTabPath) return
return activeTabPath ? gitHistory : []
let cancelled = false
const timeoutId = window.setTimeout(() => {
void loadGitHistory(activeTabPath).then((history) => {
if (cancelled) return
setLoadedHistory({
path: activeTabPath,
commits: history,
})
})
}, GIT_HISTORY_LOAD_DELAY_MS)
return () => {
cancelled = true
window.clearTimeout(timeoutId)
}
}, [activeTabPath, enabled, loadGitHistory])
return enabled && activeTabPath && loadedHistory.path === activeTabPath
? loadedHistory.commits
: []
}

View File

@@ -195,6 +195,55 @@ describe('useVaultSwitcher', () => {
expect(result.current.isGettingStartedHidden).toBe(false)
})
it('drops stale canonical Getting Started entries when the starter path is missing', async () => {
mockVaultListStore = {
vaults: [
{ label: 'Getting Started', path: expectedDefaultVaultPath },
{ label: 'Work', path: '/work/vault' },
],
active_vault: '/work/vault',
hidden_defaults: [],
}
setMockInvokeBehavior({
checkVaultExists: ({ path }) => path === '/work/vault',
})
const { result } = await renderLoadedVaultSwitcher()
expect(result.current.allVaults).toEqual([{ label: 'Work', path: '/work/vault', available: true }])
expect(result.current.vaultPath).toBe('/work/vault')
await waitFor(() => {
expect(mockVaultListStore).toEqual({
vaults: [{ label: 'Work', path: '/work/vault' }],
active_vault: '/work/vault',
hidden_defaults: [],
})
})
})
it('clears a stale canonical Getting Started selection when the starter path is missing', async () => {
mockVaultListStore = {
vaults: [{ label: 'Getting Started', path: expectedDefaultVaultPath }],
active_vault: expectedDefaultVaultPath,
hidden_defaults: [],
}
setMockInvokeBehavior({ checkVaultExists: false })
const { result } = await renderLoadedVaultSwitcher()
expect(result.current.allVaults).toEqual([])
expect(result.current.selectedVaultPath).toBeNull()
await waitFor(() => {
expect(mockVaultListStore).toEqual({
vaults: [],
active_vault: null,
hidden_defaults: [],
})
})
})
it('handles load error gracefully', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
mockInvokeFn.mockImplementation((cmd: string) => {

View File

@@ -137,6 +137,18 @@ function syncDefaultVaultExport(path: string) {
DEFAULT_VAULTS[0] = { label: GETTING_STARTED_LABEL, path }
}
function isCanonicalGettingStartedPath(path: string, resolvedDefaultPath: string): boolean {
return path === resolvedDefaultPath
}
function isUnavailableGettingStartedVault(vault: VaultOption): boolean {
return vault.label === GETTING_STARTED_LABEL && vault.available === false
}
function shouldDropPersistedGettingStartedVault(vault: VaultOption, resolvedDefaultPath: string): boolean {
return isCanonicalGettingStartedPath(vault.path, resolvedDefaultPath) || isUnavailableGettingStartedVault(vault)
}
async function checkVaultAvailability(path: string): Promise<boolean> {
if (!path) {
return false
@@ -166,7 +178,79 @@ async function loadInitialVaultState() {
console.warn('Failed to load vault list:', vaultListResult.reason)
}
return { activeVault, defaultAvailable, hiddenDefaults, resolvedDefaultPath, vaults }
return sanitizeCanonicalGettingStartedState({
activeVault,
defaultAvailable,
hiddenDefaults,
resolvedDefaultPath,
vaults,
})
}
function sanitizeCanonicalGettingStartedState({
activeVault,
defaultAvailable,
hiddenDefaults,
resolvedDefaultPath,
vaults,
}: {
activeVault: string | null
defaultAvailable: boolean
hiddenDefaults: string[]
resolvedDefaultPath: string
vaults: VaultOption[]
}) {
if (!resolvedDefaultPath) {
return { activeVault, defaultAvailable, hiddenDefaults, resolvedDefaultPath, vaults }
}
const filteredVaults = vaults.filter(
(vault) => !shouldDropPersistedGettingStartedVault(vault, resolvedDefaultPath),
)
const removedStarterPaths = new Set(
vaults
.filter((vault) => shouldDropPersistedGettingStartedVault(vault, resolvedDefaultPath))
.map((vault) => vault.path),
)
const sanitizedActiveVault = resolveSanitizedGettingStartedSelection({
activeVault,
defaultAvailable,
filteredVaults,
removedStarterPaths,
resolvedDefaultPath,
})
return {
activeVault: sanitizedActiveVault,
defaultAvailable,
hiddenDefaults,
resolvedDefaultPath,
vaults: filteredVaults,
}
}
function resolveSanitizedGettingStartedSelection({
activeVault,
defaultAvailable,
filteredVaults,
removedStarterPaths,
resolvedDefaultPath,
}: {
activeVault: string | null
defaultAvailable: boolean
filteredVaults: VaultOption[]
removedStarterPaths: Set<string>
resolvedDefaultPath: string
}): string | null {
if (!activeVault || !removedStarterPaths.has(activeVault)) {
return activeVault
}
if (isCanonicalGettingStartedPath(activeVault, resolvedDefaultPath) && defaultAvailable) {
return activeVault
}
return filteredVaults[0]?.path ?? null
}
function buildDefaultVaults({

View File

@@ -1,8 +1,5 @@
import * as Sentry from '@sentry/react'
const SENTRY_DSN = import.meta.env.VITE_SENTRY_DSN ?? ''
const POSTHOG_KEY = import.meta.env.VITE_POSTHOG_KEY ?? ''
const POSTHOG_HOST = import.meta.env.VITE_POSTHOG_HOST ?? 'https://us.i.posthog.com'
import { resolveFrontendTelemetryConfig } from './telemetryConfig'
/** Pattern that matches absolute file paths (macOS / Linux / Windows). */
const PATH_PATTERN = /(?:\/[\w.-]+){2,}|[A-Z]:\\[\w\\.-]+/g
@@ -11,24 +8,30 @@ function scrubPaths(input: string): string {
return input.replace(PATH_PATTERN, '<redacted-path>')
}
function scrubSentryEvent(event: Sentry.ErrorEvent): Sentry.ErrorEvent {
if (event.message) event.message = scrubPaths(event.message)
for (const ex of event.exception?.values ?? []) {
if (ex.value) ex.value = scrubPaths(ex.value)
}
for (const breadcrumb of event.breadcrumbs ?? []) {
if (breadcrumb.message) breadcrumb.message = scrubPaths(breadcrumb.message)
}
return event
}
let sentryInitialized = false
let posthogInstance: typeof import('posthog-js').default | null = null
export function initSentry(anonymousId: string): void {
if (sentryInitialized || !SENTRY_DSN) return
if (sentryInitialized) return
const { sentryDsn } = resolveFrontendTelemetryConfig()
if (!sentryDsn) return
Sentry.init({
dsn: SENTRY_DSN,
dsn: sentryDsn,
sendDefaultPii: false,
beforeSend(event) {
if (event.message) event.message = scrubPaths(event.message)
for (const ex of event.exception?.values ?? []) {
if (ex.value) ex.value = scrubPaths(ex.value)
}
for (const bc of event.breadcrumbs ?? []) {
if (bc.message) bc.message = scrubPaths(bc.message)
}
return event
},
beforeSend: scrubSentryEvent,
})
Sentry.setUser({ id: anonymousId })
sentryInitialized = true
@@ -41,10 +44,14 @@ export function teardownSentry(): void {
}
export async function initPostHog(anonymousId: string, releaseChannel?: string): Promise<void> {
if (posthogInstance || !POSTHOG_KEY) return
if (posthogInstance) return
const { posthogKey, posthogHost } = resolveFrontendTelemetryConfig()
if (!posthogKey || !posthogHost) return
const posthog = (await import('posthog-js')).default
posthog.init(POSTHOG_KEY, {
api_host: POSTHOG_HOST,
posthog.init(posthogKey, {
api_host: posthogHost,
autocapture: false,
capture_pageview: false,
persistence: 'memory',

View File

@@ -0,0 +1,71 @@
import { describe, expect, it } from 'vitest'
import {
_defaultPostHogHostForTest as defaultPostHogHost,
resolveFrontendTelemetryConfig,
sanitizeTelemetryEnvValue,
} from './telemetryConfig'
describe('sanitizeTelemetryEnvValue', () => {
it('trims surrounding whitespace', () => {
expect(sanitizeTelemetryEnvValue(' value ')).toBe('value')
})
it('unwraps matching quotes after trimming', () => {
expect(sanitizeTelemetryEnvValue(' "value" ')).toBe('value')
expect(sanitizeTelemetryEnvValue(" 'value' ")).toBe('value')
})
it('returns an empty string for blank input', () => {
expect(sanitizeTelemetryEnvValue(' ')).toBe('')
expect(sanitizeTelemetryEnvValue(undefined)).toBe('')
})
})
describe('resolveFrontendTelemetryConfig', () => {
it('keeps valid telemetry values after sanitizing them', () => {
expect(resolveFrontendTelemetryConfig({
VITE_SENTRY_DSN: ' "https://public@example.ingest.sentry.io/123456" ',
VITE_POSTHOG_KEY: " 'phc_test_key' ",
VITE_POSTHOG_HOST: ' https://eu.i.posthog.com ',
})).toEqual({
sentryDsn: 'https://public@example.ingest.sentry.io/123456',
posthogKey: 'phc_test_key',
posthogHost: 'https://eu.i.posthog.com',
})
})
it('uses the default PostHog host when one is not configured', () => {
expect(resolveFrontendTelemetryConfig({
VITE_SENTRY_DSN: 'https://public@example.ingest.sentry.io/123456',
VITE_POSTHOG_KEY: 'phc_test_key',
}).posthogHost).toBe(defaultPostHogHost)
})
it('adds https to scheme-less DSNs and PostHog hosts', () => {
expect(resolveFrontendTelemetryConfig({
VITE_SENTRY_DSN: 'public@example.ingest.sentry.io/123456',
VITE_POSTHOG_KEY: 'phc_test_key',
VITE_POSTHOG_HOST: 'eu.i.posthog.com',
})).toEqual({
sentryDsn: 'https://public@example.ingest.sentry.io/123456',
posthogKey: 'phc_test_key',
posthogHost: 'https://eu.i.posthog.com',
})
})
it('drops invalid Sentry DSNs instead of passing them to the SDK', () => {
expect(resolveFrontendTelemetryConfig({
VITE_SENTRY_DSN: 'not a dsn',
VITE_POSTHOG_KEY: 'phc_test_key',
VITE_POSTHOG_HOST: 'https://eu.i.posthog.com',
}).sentryDsn).toBe('')
})
it('drops invalid PostHog hosts instead of loading scripts from them', () => {
expect(resolveFrontendTelemetryConfig({
VITE_SENTRY_DSN: 'https://public@example.ingest.sentry.io/123456',
VITE_POSTHOG_KEY: 'phc_test_key',
VITE_POSTHOG_HOST: 'not a url',
}).posthogHost).toBeNull()
})
})

View File

@@ -0,0 +1,75 @@
const DEFAULT_POSTHOG_HOST = 'https://us.i.posthog.com'
type TelemetryEnv = {
VITE_SENTRY_DSN?: string
VITE_POSTHOG_KEY?: string
VITE_POSTHOG_HOST?: string
}
export type FrontendTelemetryConfig = {
sentryDsn: string
posthogKey: string
posthogHost: string | null
}
function unwrapMatchingQuotes(value: string): string {
if (value.length < 2) return value
const first = value[0]
const last = value[value.length - 1]
if (first !== last) return value
if (first !== '"' && first !== "'") return value
return value.slice(1, -1).trim()
}
export function sanitizeTelemetryEnvValue(value: string | undefined): string {
if (!value) return ''
const trimmed = value.trim()
if (!trimmed) return ''
return unwrapMatchingQuotes(trimmed)
}
function isHttpUrl(value: string): boolean {
try {
const url = new URL(value)
return url.protocol === 'http:' || url.protocol === 'https:'
} catch {
return false
}
}
function normalizeHttpLikeValue(value: string): string {
if (!value) return ''
if (/^[a-z][a-z\d+\-.]*:\/\//i.test(value)) return value
return `https://${value}`
}
function normalizeSentryDsn(value: string): string {
const normalized = normalizeHttpLikeValue(value)
return isHttpUrl(normalized) ? normalized : ''
}
function normalizePostHogHost(value: string): string | null {
if (!value) return DEFAULT_POSTHOG_HOST
const normalized = normalizeHttpLikeValue(value)
return isHttpUrl(normalized) ? normalized : null
}
export function resolveFrontendTelemetryConfig(
env: TelemetryEnv = import.meta.env as TelemetryEnv,
): FrontendTelemetryConfig {
const sentryDsn = normalizeSentryDsn(
sanitizeTelemetryEnvValue(env.VITE_SENTRY_DSN),
)
const posthogKey = sanitizeTelemetryEnvValue(env.VITE_POSTHOG_KEY)
const posthogHost = normalizePostHogHost(
sanitizeTelemetryEnvValue(env.VITE_POSTHOG_HOST),
)
return { sentryDsn, posthogKey, posthogHost }
}
export { DEFAULT_POSTHOG_HOST as _defaultPostHogHostForTest }

View File

@@ -0,0 +1,135 @@
import { test, expect, type Page } from '@playwright/test'
interface StaleStarterPaths {
gettingStartedPath: string
personalVaultPath: string
workVaultPath: string
}
function buildPaths(): StaleStarterPaths {
return {
gettingStartedPath: '/Users/mock/Documents/Getting Started',
personalVaultPath: '/Users/mock/Personal',
workVaultPath: '/Users/mock/Work',
}
}
async function installStaleStarterMocks(page: Page) {
const paths = buildPaths()
await page.addInitScript((data: StaleStarterPaths) => {
localStorage.clear()
localStorage.setItem('tolaria:claude-code-onboarding-dismissed', '1')
let ref: Record<string, unknown> | null = null
Object.defineProperty(window, '__mockHandlers', {
configurable: true,
set(value) {
ref = value as Record<string, unknown>
ref.load_vault_list = () => ({
vaults: [
{ label: 'Getting Started', path: data.gettingStartedPath },
{ label: 'Work Vault', path: data.workVaultPath },
{ label: 'Personal Vault', path: data.personalVaultPath },
],
active_vault: data.workVaultPath,
hidden_defaults: [],
})
ref.get_default_vault_path = () => data.gettingStartedPath
ref.check_vault_exists = (args: { path?: string }) =>
args?.path === data.workVaultPath || args?.path === data.personalVaultPath
ref.list_vault = (args: { path?: string }) => {
if (args?.path === data.workVaultPath) {
return [{
path: `${data.workVaultPath}/work-home.md`,
filename: 'work-home.md',
title: 'Work Home',
isA: 'Note',
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
archived: false,
modifiedAt: 1700000000,
createdAt: null,
fileSize: 256,
snippet: 'Work Home snippet',
wordCount: 12,
relationships: {},
outgoingLinks: [],
properties: {},
template: null,
sort: null,
}]
}
if (args?.path === data.personalVaultPath) {
return [{
path: `${data.personalVaultPath}/personal-home.md`,
filename: 'personal-home.md',
title: 'Personal Home',
isA: 'Note',
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
archived: false,
modifiedAt: 1700000000,
createdAt: null,
fileSize: 256,
snippet: 'Personal Home snippet',
wordCount: 12,
relationships: {},
outgoingLinks: [],
properties: {},
template: null,
sort: null,
}]
}
return []
}
ref.list_vault_folders = () => []
ref.list_views = () => []
ref.get_all_content = () => ({
[`${data.workVaultPath}/work-home.md`]: '# Work Home\n\nWork Home snippet',
[`${data.personalVaultPath}/personal-home.md`]: '# Personal Home\n\nPersonal Home snippet',
})
ref.get_note_content = (args: { path?: string }) => {
if (args?.path === `${data.workVaultPath}/work-home.md`) {
return '# Work Home\n\nWork Home snippet'
}
if (args?.path === `${data.personalVaultPath}/personal-home.md`) {
return '# Personal Home\n\nPersonal Home snippet'
}
return ''
}
ref.get_modified_files = () => []
ref.get_file_history = () => []
},
get() {
return ref
},
})
}, paths)
}
test('stale persisted Getting Started entries stay hidden when the starter path is missing @smoke', async ({ page }) => {
await installStaleStarterMocks(page)
await page.goto('/', { waitUntil: 'domcontentloaded' })
const trigger = page.getByTestId('status-vault-trigger')
await expect(trigger).toContainText('Work Vault')
await trigger.focus()
await expect(trigger).toBeFocused()
await page.keyboard.press('Enter')
await expect(page.getByTestId('vault-menu-item-Getting Started')).toHaveCount(0)
await expect(page.getByTestId('vault-menu-item-Work Vault')).toBeVisible()
await expect(page.getByTestId('vault-menu-item-Personal Vault')).toBeVisible()
})