diff --git a/.github/workflows/deploy-cloudflare-workers.yml b/.github/workflows/deploy-cloudflare-workers.yml index 965114789..e2e33e20f 100644 --- a/.github/workflows/deploy-cloudflare-workers.yml +++ b/.github/workflows/deploy-cloudflare-workers.yml @@ -78,6 +78,7 @@ jobs: S3_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_ACCESS_KEY }} S3_ENDPOINT: ${{ secrets.S3_ENDPOINT }} S3_REGION: ${{ secrets.S3_REGION }} + VITE_ENABLE_POSTHOG: 'true' WARP_DRIVE_PUBLIC_BASE: ${{ secrets.WARP_DRIVE_PUBLIC_BASE }} - run: | CI=true pnpm install -g @posthog/cli diff --git a/.github/workflows/deploy-huggingface-spaces.yml b/.github/workflows/deploy-huggingface-spaces.yml index cc781eec5..a13b8b6c6 100644 --- a/.github/workflows/deploy-huggingface-spaces.yml +++ b/.github/workflows/deploy-huggingface-spaces.yml @@ -33,6 +33,7 @@ jobs: env: TARGET_HUGGINGFACE_SPACE: 'true' VITE_APP_TARGET_HUGGINGFACE_SPACE: 'true' + VITE_ENABLE_POSTHOG: 'true' - id: airi_diff working-directory: ./apps/stage-web/dist diff --git a/.github/workflows/release-docker.yaml b/.github/workflows/release-docker.yaml index 058dadcd9..5d4a6bcb7 100644 --- a/.github/workflows/release-docker.yaml +++ b/.github/workflows/release-docker.yaml @@ -45,6 +45,8 @@ jobs: with: context: ./ file: ./apps/stage-web/Dockerfile + build-args: | + VITE_ENABLE_POSTHOG=true platforms: linux/amd64,linux/arm64,linux/arm64/v8 cache-from: type=gha cache-to: type=gha,mode=max diff --git a/.github/workflows/release-pocket-android.yml b/.github/workflows/release-pocket-android.yml index f1a2f539d..644104e5a 100644 --- a/.github/workflows/release-pocket-android.yml +++ b/.github/workflows/release-pocket-android.yml @@ -23,6 +23,9 @@ jobs: # TODO: build release APK name: Build Debuggable Android APK runs-on: ubuntu-latest + env: + # Every current trigger path produces an installable APK artifact, so ship the analytics-enabled build consistently. + VITE_ENABLE_POSTHOG: 'true' steps: - uses: actions/checkout@v6 diff --git a/.github/workflows/release-tamagotchi.yml b/.github/workflows/release-tamagotchi.yml index 50aee9bae..6107120a9 100644 --- a/.github/workflows/release-tamagotchi.yml +++ b/.github/workflows/release-tamagotchi.yml @@ -47,6 +47,8 @@ jobs: build: name: Build continue-on-error: ${{ matrix.skip }} # WORKAROUND: Skipping subsequent steps without failing the whole workflow + env: + VITE_ENABLE_POSTHOG: ${{ ((github.event_name == 'release') || (github.event_name == 'workflow_dispatch' && !inputs.build_only)) && 'true' || 'false' }} strategy: matrix: include: diff --git a/apps/stage-pocket/src/App.vue b/apps/stage-pocket/src/App.vue index b08708bf7..ec5ddee4f 100644 --- a/apps/stage-pocket/src/App.vue +++ b/apps/stage-pocket/src/App.vue @@ -1,6 +1,6 @@ - - - - - - - - - - - - - + diff --git a/packages/stage-ui/package.json b/packages/stage-ui/package.json index 6ac3e14d4..edb71223d 100644 --- a/packages/stage-ui/package.json +++ b/packages/stage-ui/package.json @@ -28,7 +28,10 @@ "./libs": "./src/libs/index.ts", "./stores/providers/aliyun": "./src/stores/providers/aliyun/index.ts", "./stores/analytics": "./src/stores/analytics/index.ts", + "./stores/analytics/posthog": "./src/stores/analytics/posthog.ts", + "./stores/analytics/privacy-policy": "./src/stores/analytics/privacy-policy.ts", "./stores/character": "./src/stores/character/index.ts", + "./stores/settings/analytics": "./src/stores/settings/analytics.ts", "./stores/settings": "./src/stores/settings/index.ts", "./stores/modules/vision": "./src/stores/modules/vision/index.ts", "./stores/*": "./src/stores/*.ts", diff --git a/packages/stage-ui/src/components/scenarios/dialogs/onboarding/index.ts b/packages/stage-ui/src/components/scenarios/dialogs/onboarding/index.ts index 75871d6db..18dd8f234 100644 --- a/packages/stage-ui/src/components/scenarios/dialogs/onboarding/index.ts +++ b/packages/stage-ui/src/components/scenarios/dialogs/onboarding/index.ts @@ -1,3 +1,4 @@ export { default as OnboardingDialog } from './onboarding-dialog.vue' export { default as OnboardingScreen } from './onboarding.vue' +export { default as OnboardingStepAnalyticsNotice } from './step-analytics-notice.vue' export type { OnboardingStep } from './types' diff --git a/packages/stage-ui/src/components/scenarios/dialogs/onboarding/step-analytics-notice.vue b/packages/stage-ui/src/components/scenarios/dialogs/onboarding/step-analytics-notice.vue new file mode 100644 index 000000000..f6e8b9dc0 --- /dev/null +++ b/packages/stage-ui/src/components/scenarios/dialogs/onboarding/step-analytics-notice.vue @@ -0,0 +1,63 @@ + + + + + + + + + + {{ t('settings.analytics.notice.title') }} + + + + + + + + {{ t('settings.analytics.notice.description') }} + + {{ t('settings.analytics.notice.privacyPrefix') }} + + {{ t('settings.analytics.notice.privacyLink') }} + . + + {{ t('settings.analytics.notice.onboardingHint') }} + + + + + + + diff --git a/packages/stage-ui/src/composables/use-analytics.ts b/packages/stage-ui/src/composables/use-analytics.ts index 56ce8418f..5aaf8e93f 100644 --- a/packages/stage-ui/src/composables/use-analytics.ts +++ b/packages/stage-ui/src/composables/use-analytics.ts @@ -1,11 +1,36 @@ import posthog from 'posthog-js' +import { computed } from 'vue' +import { useI18n } from 'vue-i18n' + import { useSharedAnalyticsStore } from '../stores/analytics' +import { ensurePosthogInitialized, isPosthogAvailableInBuild } from '../stores/analytics/posthog' +import { getAnalyticsPrivacyPolicyUrl } from '../stores/analytics/privacy-policy' +import { useSettingsAnalytics } from '../stores/settings/analytics' +import { useSettingsGeneral } from '../stores/settings/general' export function useAnalytics() { const analyticsStore = useSharedAnalyticsStore() + const settingsAnalytics = useSettingsAnalytics() + const settingsGeneral = useSettingsGeneral() + const { locale } = useI18n() + + const privacyPolicyUrl = computed(() => getAnalyticsPrivacyPolicyUrl(locale.value || settingsGeneral.language)) + + const isAnalyticsEnabled = computed(() => isPosthogAvailableInBuild() && settingsAnalytics.analyticsEnabled) + + function canCapture(): boolean { + if (!isAnalyticsEnabled.value) + return false + + // Ensure PostHog is initialized before any capture call. + return ensurePosthogInitialized(true) + } function trackProviderClick(providerId: string, module: string) { + if (!canCapture()) + return + posthog.capture('provider_card_clicked', { provider_id: providerId, module, @@ -13,6 +38,9 @@ export function useAnalytics() { } function trackFirstMessage() { + if (!canCapture()) + return + // Only track the first message once if (analyticsStore.firstMessageTracked) return @@ -30,6 +58,7 @@ export function useAnalytics() { } return { + privacyPolicyUrl, trackProviderClick, trackFirstMessage, } diff --git a/packages/stage-ui/src/stores/analytics/index.ts b/packages/stage-ui/src/stores/analytics/index.ts index 05ef03a4a..af23b2ffc 100644 --- a/packages/stage-ui/src/stores/analytics/index.ts +++ b/packages/stage-ui/src/stores/analytics/index.ts @@ -1,32 +1,57 @@ import type { AboutBuildInfo } from '../../components/scenarios/about/types' -import posthog from 'posthog-js' +import { defineStore, storeToRefs } from 'pinia' +import { ref, watch } from 'vue' -import { defineStore } from 'pinia' -import { ref } from 'vue' +import { useBuildInfo } from '../../composables/use-build-info' +import { useSettingsAnalytics } from '../settings/analytics' +import { + isPosthogAvailableInBuild, + registerPosthogBuildInfo, + syncPosthogCapture, +} from './posthog' -import { useBuildInfo } from '../../composables' +export * from './posthog' +export * from './privacy-policy' export const useSharedAnalyticsStore = defineStore('analytics-shared', () => { const buildInfo = ref(useBuildInfo()) + const settingsAnalytics = useSettingsAnalytics() + const { analyticsEnabled } = storeToRefs(settingsAnalytics) const isInitialized = ref(false) const appStartTime = ref(null) const firstMessageTracked = ref(false) + watch(analyticsEnabled, (enabled, previousEnabled) => { + if (!isInitialized.value) + return + + const shouldCapture = syncPosthogCapture(enabled) + if (shouldCapture) { + // When analytics is enabled mid-session, invalidate appStartTime and + // mark first message as already tracked to avoid backfilling a stale + // event with a misleading duration or timing. + if (!previousEnabled && !firstMessageTracked.value) { + appStartTime.value = null + markFirstMessageTracked() + } + + registerPosthogBuildInfo(buildInfo.value) + } + }) + function initialize() { if (isInitialized.value) return appStartTime.value = Date.now() - // Register metadata with PostHog after buildInfo is set - posthog.register({ - app_version: (buildInfo.value.version && buildInfo.value.version !== '0.0.0') ? buildInfo.value.version : 'dev', - app_commit: buildInfo.value.commit, - app_branch: buildInfo.value.branch, - app_build_time: buildInfo.value.builtOn, - }) + if (isPosthogAvailableInBuild()) { + const shouldCapture = syncPosthogCapture(analyticsEnabled.value) + if (shouldCapture) + registerPosthogBuildInfo(buildInfo.value) + } isInitialized.value = true } diff --git a/packages/stage-ui/src/stores/analytics/posthog.ts b/packages/stage-ui/src/stores/analytics/posthog.ts new file mode 100644 index 000000000..4fe9f2165 --- /dev/null +++ b/packages/stage-ui/src/stores/analytics/posthog.ts @@ -0,0 +1,75 @@ +import type { AboutBuildInfo } from '../../components/scenarios/about/types' + +import posthog from 'posthog-js' + +import { isStageCapacitor, isStageTamagotchi } from '@proj-airi/stage-shared' + +import { + DEFAULT_POSTHOG_CONFIG, + POSTHOG_ENABLED, + POSTHOG_PROJECT_KEY_DESKTOP, + POSTHOG_PROJECT_KEY_POCKET, + POSTHOG_PROJECT_KEY_WEB, +} from '../../../../../posthog.config' + +let posthogInitialized = false + +function getPosthogProjectKey(): string { + if (isStageTamagotchi()) + return POSTHOG_PROJECT_KEY_DESKTOP + + if (isStageCapacitor()) + return POSTHOG_PROJECT_KEY_POCKET + + return POSTHOG_PROJECT_KEY_WEB +} + +export function isPosthogAvailableInBuild(): boolean { + return POSTHOG_ENABLED +} + +export function ensurePosthogInitialized(enabled: boolean): boolean { + if (!POSTHOG_ENABLED) + return false + + if (posthogInitialized) + return true + + posthog.init(getPosthogProjectKey(), { + ...DEFAULT_POSTHOG_CONFIG, + opt_out_capturing_by_default: !enabled, + }) + posthogInitialized = true + return true +} + +export function syncPosthogCapture(enabled: boolean): boolean { + if (!POSTHOG_ENABLED) + return false + + if (enabled) { + ensurePosthogInitialized(true) + + if (posthog.has_opted_out_capturing()) + posthog.opt_in_capturing() + + return true + } + + if (posthogInitialized && !posthog.has_opted_out_capturing()) + posthog.opt_out_capturing() + + return false +} + +export function registerPosthogBuildInfo(buildInfo: AboutBuildInfo): void { + if (!posthogInitialized) + return + + posthog.register({ + app_version: (buildInfo.version && buildInfo.version !== '0.0.0') ? buildInfo.version : 'dev', + app_commit: buildInfo.commit, + app_branch: buildInfo.branch, + app_build_time: buildInfo.builtOn, + }) +} diff --git a/packages/stage-ui/src/stores/analytics/privacy-policy.ts b/packages/stage-ui/src/stores/analytics/privacy-policy.ts new file mode 100644 index 000000000..6f4c54e45 --- /dev/null +++ b/packages/stage-ui/src/stores/analytics/privacy-policy.ts @@ -0,0 +1,18 @@ +import { localeRemap } from '@proj-airi/i18n' + +// TODO: Replace this with docs-owned metadata so app links cannot drift from +// the actual locales published under docs/content/*/about/privacy.md. +const supportedPrivacyPolicyLocales = new Set([ + 'en', + 'ja', + 'zh-Hans', +]) + +export function getAnalyticsPrivacyPolicyUrl(locale?: string): string { + const normalizedLocale = localeRemap[locale ?? 'en'] ?? locale ?? 'en' + const docsLocale = supportedPrivacyPolicyLocales.has(normalizedLocale) + ? normalizedLocale + : 'en' + + return `https://airi.moeru.ai/docs/${docsLocale}/about/privacy` +} diff --git a/packages/stage-ui/src/stores/settings/analytics.ts b/packages/stage-ui/src/stores/settings/analytics.ts new file mode 100644 index 000000000..c0953e5b6 --- /dev/null +++ b/packages/stage-ui/src/stores/settings/analytics.ts @@ -0,0 +1,15 @@ +import { useLocalStorageManualReset } from '@proj-airi/stage-shared/composables' +import { defineStore } from 'pinia' + +export const useSettingsAnalytics = defineStore('settings-analytics', () => { + const analyticsEnabled = useLocalStorageManualReset('settings/analytics/enabled', true) + + function resetState() { + analyticsEnabled.reset() + } + + return { + analyticsEnabled, + resetState, + } +}) diff --git a/packages/stage-ui/src/stores/settings/general.ts b/packages/stage-ui/src/stores/settings/general.ts index e3951f14a..93d4688e3 100644 --- a/packages/stage-ui/src/stores/settings/general.ts +++ b/packages/stage-ui/src/stores/settings/general.ts @@ -1,30 +1,10 @@ import messages from '@proj-airi/i18n/locales' +import { resolveSupportedLocale } from '@proj-airi/i18n' import { useLocalStorageManualReset } from '@proj-airi/stage-shared/composables' import { defineStore } from 'pinia' import { onMounted } from 'vue' -const languageRemap: Record = { - 'zh-CN': 'zh-Hans', - 'zh-TW': 'zh-Hans', // TODO: remove this when zh-Hant is supported - 'zh-HK': 'zh-Hans', // TODO: remove this when zh-Hant is supported - 'zh-Hant': 'zh-Hans', // TODO: remove this when zh-Hant is supported - 'en-US': 'en', - 'en-GB': 'en', - 'en-AU': 'en', - 'en': 'en', - 'es-ES': 'es', - 'es-MX': 'es', - 'es-AR': 'es', - 'es': 'es', - 'ru': 'ru', - 'ru-RU': 'ru', - 'fr': 'fr', - 'fr-FR': 'fr', - 'ja': 'ja', - 'ja-JP': 'ja', -} - export const useSettingsGeneral = defineStore('settings-general', () => { const language = useLocalStorageManualReset('settings/language', '') @@ -41,14 +21,7 @@ export const useSettingsGeneral = defineStore('settings-general', () => { language = navigator.language || 'en' } - const languages = Object.keys(messages!) - if (languageRemap[language || 'en'] != null) { - language = languageRemap[language || 'en'] - } - if (language && languages.includes(language)) - return language - - return 'en' + return resolveSupportedLocale(language, Object.keys(messages!)) } function resetState() { diff --git a/packages/stage-ui/src/stores/settings/index.ts b/packages/stage-ui/src/stores/settings/index.ts index 032ffd5b6..96604b291 100644 --- a/packages/stage-ui/src/stores/settings/index.ts +++ b/packages/stage-ui/src/stores/settings/index.ts @@ -1,11 +1,13 @@ import { defineStore, storeToRefs } from 'pinia' +import { useSettingsAnalytics } from './analytics' import { useSettingsControlsIsland } from './controls-island' import { useSettingsGeneral } from './general' import { useSettingsLive2d } from './live2d' import { useSettingsStageModel } from './stage-model' import { useSettingsTheme } from './theme' +export * from './analytics' // Export sub-stores export * from './audio-device' export * from './controls-island' @@ -25,6 +27,7 @@ export { DEFAULT_THEME_COLORS_HUE } from './theme' */ export const useSettings = defineStore('settings', () => { const general = useSettingsGeneral() + const analytics = useSettingsAnalytics() const stageModel = useSettingsStageModel() const live2d = useSettingsLive2d() const theme = useSettingsTheme() @@ -32,6 +35,7 @@ export const useSettings = defineStore('settings', () => { async function resetState() { await stageModel.resetState() + analytics.resetState() general.resetState() live2d.resetState() theme.resetState() @@ -40,6 +44,7 @@ export const useSettings = defineStore('settings', () => { // Extract refs from sub-stores to maintain proper reactivity const generalRefs = storeToRefs(general) + const analyticsRefs = storeToRefs(analytics) const stageModelRefs = storeToRefs(stageModel) const live2dRefs = storeToRefs(live2d) const themeRefs = storeToRefs(theme) @@ -50,6 +55,7 @@ export const useSettings = defineStore('settings', () => { disableTransitions: generalRefs.disableTransitions, usePageSpecificTransitions: generalRefs.usePageSpecificTransitions, language: generalRefs.language, + analyticsEnabled: analyticsRefs.analyticsEnabled, websocketSecureEnabled: generalRefs.websocketSecureEnabled, // Stage model settings diff --git a/packages/ui/src/components/form/checkbox/checkbox.vue b/packages/ui/src/components/form/checkbox/checkbox.vue index f9d7d96e0..72fcd4171 100644 --- a/packages/ui/src/components/form/checkbox/checkbox.vue +++ b/packages/ui/src/components/form/checkbox/checkbox.vue @@ -1,12 +1,17 @@ () const modelValue = defineModel({ required: true }) @@ -24,7 +25,7 @@ const modelValue = defineModel({ required: true }) - + diff --git a/posthog.config.ts b/posthog.config.ts index 82f9f792c..de9c1588f 100644 --- a/posthog.config.ts +++ b/posthog.config.ts @@ -2,8 +2,15 @@ import type { PostHogConfig } from 'posthog-js' -// These PostHog project API keys are considered public and are safe -// to be stored here as clear text. +function isEnvFlagEnabled(value: string | undefined): boolean { + if (value == null) + return false + + return /^(?:1|true|t|yes|y|on)$/i.test(value.trim()) +} + +// For Release workflows set `VITE_ENABLE_POSTHOG=true`. +export const POSTHOG_ENABLED = isEnvFlagEnabled(import.meta.env.VITE_ENABLE_POSTHOG) export const POSTHOG_PROJECT_KEY_WEB = import.meta.env.VITE_POSTHOG_PROJECT_KEY_WEB diff --git a/vite-env.d.ts b/vite-env.d.ts index 5ffa92c98..012bcde28 100644 --- a/vite-env.d.ts +++ b/vite-env.d.ts @@ -1,6 +1,7 @@ /// interface ImportMetaEnv { + readonly VITE_ENABLE_POSTHOG?: string readonly VITE_POSTHOG_PROJECT_KEY_WEB?: string readonly VITE_POSTHOG_PROJECT_KEY_DESKTOP?: string readonly VITE_POSTHOG_PROJECT_KEY_POCKET?: string
{{ t('settings.analytics.notice.description') }}
+ {{ t('settings.analytics.notice.privacyPrefix') }} + + {{ t('settings.analytics.notice.privacyLink') }} + . +
{{ t('settings.analytics.notice.onboardingHint') }}