Skip to content

Commit 3e3d6df

Browse files
committed
Merge remote-tracking branch 'origin/main' into fix/restore-main-window-bounds-2181
2 parents ca097a2 + b230e16 commit 3e3d6df

371 files changed

Lines changed: 10975 additions & 10750 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,8 @@ apps/stage-tamagotchi/electron.vite.config.*.mjs
144144
docs/ai/context/verifications/
145145
.playwright-mcp/
146146
.vishot/
147+
.alint/
148+
.alintcache
147149

148150
# Generated from packages/i18n/glossary/terms.yaml at upload time. Never committed, so it
149151
# cannot drift from its source.

alint.config.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import jsPlugin from '@alint-js/plugin-js'
2+
3+
import { defineConfig } from '@alint-js/cli'
4+
5+
export default defineConfig([
6+
{
7+
extends: ['js/recommended'],
8+
files: ['**/*.{js,jsx,ts,tsx,mjs,cjs,mts,cts}'],
9+
plugins: {
10+
js: jsPlugin,
11+
},
12+
},
13+
])

apps/stage-pocket/android/app/src/main/java/ai/moeru/airi_pocket/MainActivity.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ class MainActivity : BridgeActivity() {
2727
private var webSocketBridge: HostWebSocketBridge? = null
2828

2929
override fun load() {
30+
registerPlugin(MicrophonePermissionPlugin::class.java)
3031
super.load()
3132

3233
val bridge = bridge ?: return
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package ai.moeru.airi_pocket
2+
3+
import android.Manifest
4+
import android.content.pm.PackageManager
5+
import com.getcapacitor.JSObject
6+
import com.getcapacitor.Plugin
7+
import com.getcapacitor.PluginCall
8+
import com.getcapacitor.PluginMethod
9+
import com.getcapacitor.annotation.CapacitorPlugin
10+
11+
@CapacitorPlugin(name = "MicrophonePermission")
12+
class MicrophonePermissionPlugin : Plugin() {
13+
/** Reports native permission state without opening Android's permission dialog. */
14+
@PluginMethod
15+
fun checkPermission(call: PluginCall) {
16+
val permission = Manifest.permission.RECORD_AUDIO
17+
val result = JSObject().apply {
18+
put("granted", context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED)
19+
}
20+
call.resolve(result)
21+
}
22+
}

apps/stage-pocket/src/App.vue

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<script setup lang="ts">
22
import { OnboardingDialog, OnboardingStepAnalyticsNotice, ToasterRoot } from '@proj-airi/stage-ui/components'
33
import { useAuthProviderSync } from '@proj-airi/stage-ui/composables/use-auth-provider-sync'
4-
import { isAnalyticsAvailableInBuild, useSharedAnalyticsStore } from '@proj-airi/stage-ui/stores/analytics'
4+
import { initializeAnalytics, isAnalyticsAvailableInBuild } from '@proj-airi/stage-ui/libs/analytics'
55
import { useCharacterOrchestratorStore } from '@proj-airi/stage-ui/stores/character'
66
import { useDisplayModelsStore } from '@proj-airi/stage-ui/stores/display-models'
77
import { useModsServerChannelStore } from '@proj-airi/stage-ui/stores/mods/api/channel-server'
@@ -35,7 +35,6 @@ const settingsAudioDeviceStore = useSettingsAudioDevice()
3535
const { showingSetup } = storeToRefs(onboardingStore)
3636
const { isDark } = useTheme()
3737
const cardStore = useAiriCardStore()
38-
const analyticsStore = useSharedAnalyticsStore()
3938
4039
const primaryColor = computed(() => {
4140
return isDark.value
@@ -73,7 +72,7 @@ watch(settings.themeColorsHueDynamic, () => {
7372
7473
// Initialize first-time setup check when app mounts
7574
onMounted(async () => {
76-
analyticsStore.initialize()
75+
initializeAnalytics()
7776
await displayModelsStore.initialize()
7877
cardStore.initialize()
7978

apps/stage-pocket/src/components/onboarding/step-permissions.vue

Lines changed: 3 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,8 @@
11
<script setup lang="ts">
2-
import { Capacitor } from '@capacitor/core'
3-
import { LocalNotifications } from '@capacitor/local-notifications'
42
import { Button } from '@proj-airi/ui'
5-
import { AndroidSettings, IOSSettings, NativeSettings } from 'capacitor-native-settings'
6-
import { ref } from 'vue'
73
import { useI18n } from 'vue-i18n'
84
9-
type PermissionState = boolean | undefined
5+
import PermissionsPanel from '../permissions/permissions-panel.vue'
106
117
interface Props {
128
onNext: () => Promise<void> | void
@@ -15,31 +11,6 @@ interface Props {
1511
1612
const props = defineProps<Props>()
1713
const { t } = useI18n()
18-
19-
const isNativePlatform = Capacitor.isNativePlatform()
20-
21-
const notificationPermissionGranted = ref<PermissionState>(undefined)
22-
23-
async function requestNotificationPermission() {
24-
const beforeRequest = await LocalNotifications.checkPermissions()
25-
if (beforeRequest.display === 'granted') {
26-
notificationPermissionGranted.value = true
27-
return
28-
}
29-
30-
const requested = await LocalNotifications.requestPermissions()
31-
if (requested.display === 'granted') {
32-
notificationPermissionGranted.value = true
33-
return
34-
}
35-
36-
if (isNativePlatform) {
37-
NativeSettings.open({
38-
optionAndroid: AndroidSettings.AppNotification,
39-
optionIOS: IOSSettings.AppNotification,
40-
})
41-
}
42-
}
4314
</script>
4415

4516
<template>
@@ -59,25 +30,9 @@ async function requestNotificationPermission() {
5930
{{ t('settings.dialogs.onboarding.permissions.description') }}
6031
</p>
6132

62-
<section class="border border-neutral-200 rounded-xl bg-neutral-50 p-4 dark:border-neutral-700 dark:bg-neutral-800/50">
63-
<div class="mb-3 flex items-start justify-between gap-3">
64-
<div>
65-
<h3 class="text-sm text-neutral-800 font-semibold dark:text-neutral-100">
66-
{{ t('settings.dialogs.onboarding.permissions.notificationsTitle') }}
67-
</h3>
68-
<p class="mt-1 text-xs text-neutral-600 dark:text-neutral-300">
69-
{{ t('settings.dialogs.onboarding.permissions.notificationsDescription') }}
70-
</p>
71-
</div>
72-
<span v-if="notificationPermissionGranted" class="i-solar:check-circle-linear h-5 w-5 text-green-700 dark:text-green-400" />
73-
</div>
74-
<Button
75-
:label="t('settings.dialogs.onboarding.permissions.notificationsAction')"
76-
@click="requestNotificationPermission"
77-
/>
78-
</section>
33+
<PermissionsPanel />
7934

80-
<p class="text-xs text-neutral-500 dark:text-neutral-400">
35+
<p :class="['text-xs', 'text-neutral-500 dark:text-neutral-400']">
8136
{{ t('settings.dialogs.onboarding.permissions.optionalHint') }}
8237
</p>
8338
</div>
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
<script setup lang="ts">
2+
import { Button } from '@proj-airi/ui'
3+
import { computed } from 'vue'
4+
import { useI18n } from 'vue-i18n'
5+
6+
interface Props {
7+
title: string
8+
description: string
9+
actionLabel: string
10+
granted: boolean
11+
disabled: boolean
12+
}
13+
14+
const props = defineProps<Props>()
15+
16+
const emit = defineEmits<{
17+
request: []
18+
}>()
19+
20+
const { t } = useI18n()
21+
22+
const statusLabel = computed(() => props.granted
23+
? t('settings.dialogs.onboarding.permissions.stateGranted')
24+
: t('settings.dialogs.onboarding.permissions.stateNotGranted'),
25+
)
26+
27+
const statusIcon = computed(() => props.granted
28+
? 'i-solar:check-circle-linear text-green-700 dark:text-green-400'
29+
: 'i-solar:close-circle-linear text-red-600 dark:text-red-400',
30+
)
31+
</script>
32+
33+
<template>
34+
<section
35+
:class="[
36+
'rounded-xl p-4',
37+
'border border-neutral-200 bg-neutral-50',
38+
'dark:border-neutral-700 dark:bg-neutral-800/50',
39+
]"
40+
>
41+
<div :class="['flex items-start justify-between gap-3', { 'mb-3': !props.granted }]">
42+
<div>
43+
<h3 :class="['text-sm font-semibold', 'text-neutral-800 dark:text-neutral-100']">
44+
{{ props.title }}
45+
</h3>
46+
<p :class="['mt-1 text-xs', 'text-neutral-600 dark:text-neutral-300']">
47+
{{ props.description }}
48+
</p>
49+
</div>
50+
<div
51+
:class="['flex shrink-0 items-center gap-1.5 text-xs', 'text-neutral-500 dark:text-neutral-400']"
52+
role="status"
53+
>
54+
<span :class="[statusIcon, 'h-5 w-5']" />
55+
<span>{{ statusLabel }}</span>
56+
</div>
57+
</div>
58+
<Button
59+
v-if="!props.granted"
60+
:label="props.actionLabel"
61+
:disabled="props.disabled"
62+
@click="emit('request')"
63+
/>
64+
</section>
65+
</template>
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
<script setup lang="ts">
2+
import type { PluginListenerHandle } from '@capacitor/core'
3+
4+
import { App } from '@capacitor/app'
5+
import { Capacitor } from '@capacitor/core'
6+
import { LocalNotifications } from '@capacitor/local-notifications'
7+
import { useSettingsAudioDevice } from '@proj-airi/stage-ui/stores/settings'
8+
import { useLocalStorage } from '@vueuse/core'
9+
import { AndroidSettings, IOSSettings, NativeSettings } from 'capacitor-native-settings'
10+
import { storeToRefs } from 'pinia'
11+
import { onMounted, onUnmounted, shallowRef } from 'vue'
12+
import { useI18n } from 'vue-i18n'
13+
14+
import PermissionCard from './permission-card.vue'
15+
16+
import { MicrophonePermission } from '../../modules/microphone-permission'
17+
18+
const { t } = useI18n()
19+
const audioDeviceStore = useSettingsAudioDevice()
20+
const { permissionGranted: webMicrophonePermissionGranted } = storeToRefs(audioDeviceStore)
21+
22+
const isNativePlatform = Capacitor.isNativePlatform()
23+
const isAndroid = Capacitor.getPlatform() === 'android'
24+
25+
const notificationPermissionGranted = shallowRef(false)
26+
const platformMicrophonePermissionGranted = shallowRef(false)
27+
const requestingNotificationPermission = shallowRef(false)
28+
const requestingMicrophonePermission = shallowRef(false)
29+
const microphonePermissionRequested = useLocalStorage('permissions/microphone/requested', false)
30+
31+
let appStateListener: Promise<PluginListenerHandle> | undefined
32+
33+
async function refreshNotificationPermission() {
34+
const permission = await LocalNotifications.checkPermissions()
35+
notificationPermissionGranted.value = permission.display === 'granted'
36+
}
37+
38+
async function refreshMicrophonePermission() {
39+
if (isAndroid) {
40+
const permission = await MicrophonePermission.checkPermission()
41+
platformMicrophonePermissionGranted.value = permission.granted
42+
return
43+
}
44+
45+
const permission = await navigator.permissions?.query({ name: 'microphone' }).catch(() => undefined)
46+
platformMicrophonePermissionGranted.value = permission
47+
? permission.state === 'granted'
48+
: webMicrophonePermissionGranted.value
49+
}
50+
51+
async function refreshPermissionStates() {
52+
await Promise.all([
53+
refreshNotificationPermission().catch(error => console.error('Unable to refresh notification permission:', error)),
54+
refreshMicrophonePermission().catch(error => console.error('Unable to refresh microphone permission:', error)),
55+
])
56+
}
57+
58+
async function requestNotificationPermission() {
59+
requestingNotificationPermission.value = true
60+
try {
61+
const beforeRequest = await LocalNotifications.checkPermissions()
62+
if (beforeRequest.display === 'granted') {
63+
notificationPermissionGranted.value = true
64+
return
65+
}
66+
67+
if (beforeRequest.display === 'denied') {
68+
notificationPermissionGranted.value = false
69+
if (isNativePlatform) {
70+
await NativeSettings.open({
71+
optionAndroid: AndroidSettings.AppNotification,
72+
optionIOS: IOSSettings.AppNotification,
73+
})
74+
}
75+
return
76+
}
77+
78+
const requested = await LocalNotifications.requestPermissions()
79+
notificationPermissionGranted.value = requested.display === 'granted'
80+
}
81+
finally {
82+
requestingNotificationPermission.value = false
83+
}
84+
}
85+
86+
async function requestMicrophonePermission() {
87+
requestingMicrophonePermission.value = true
88+
try {
89+
await refreshMicrophonePermission()
90+
if (platformMicrophonePermissionGranted.value)
91+
return
92+
93+
if (microphonePermissionRequested.value && isNativePlatform) {
94+
await NativeSettings.open({
95+
optionAndroid: AndroidSettings.ApplicationDetails,
96+
optionIOS: IOSSettings.App,
97+
})
98+
return
99+
}
100+
101+
// Persist before requesting so later native clicks take the settings route, including after denial.
102+
microphonePermissionRequested.value = true
103+
await audioDeviceStore.askPermission()
104+
await refreshMicrophonePermission()
105+
}
106+
finally {
107+
requestingMicrophonePermission.value = false
108+
}
109+
}
110+
111+
onMounted(() => {
112+
void refreshPermissionStates()
113+
114+
if (isNativePlatform) {
115+
appStateListener = App.addListener('appStateChange', ({ isActive }) => {
116+
if (isActive)
117+
void refreshPermissionStates()
118+
})
119+
}
120+
})
121+
122+
onUnmounted(() => {
123+
void appStateListener?.then(listener => listener.remove())
124+
})
125+
</script>
126+
127+
<template>
128+
<div :class="['flex flex-col gap-4']">
129+
<PermissionCard
130+
:title="t('settings.dialogs.onboarding.permissions.notificationsTitle')"
131+
:description="t('settings.dialogs.onboarding.permissions.notificationsDescription')"
132+
:action-label="t('settings.dialogs.onboarding.permissions.requestAction')"
133+
:granted="notificationPermissionGranted"
134+
:disabled="requestingNotificationPermission"
135+
@request="requestNotificationPermission"
136+
/>
137+
138+
<PermissionCard
139+
:title="t('settings.dialogs.onboarding.permissions.microphoneTitle')"
140+
:description="t('settings.dialogs.onboarding.permissions.microphoneDescription')"
141+
:action-label="t('settings.dialogs.onboarding.permissions.requestAction')"
142+
:granted="platformMicrophonePermissionGranted"
143+
:disabled="requestingMicrophonePermission"
144+
@request="requestMicrophonePermission"
145+
/>
146+
</div>
147+
</template>

apps/stage-pocket/src/main.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import NProgress from 'nprogress'
77
import { autoAnimatePlugin } from '@formkit/auto-animate/vue'
88
import { isEnvTruthy } from '@proj-airi/stage-shared'
99
import { trackButtonPlugin } from '@proj-airi/stage-ui/directives/track-button'
10-
import { configureAnalyticsAdapter } from '@proj-airi/stage-ui/stores/analytics/client'
10+
import { configureAnalyticsAdapter } from '@proj-airi/stage-ui/libs/analytics'
1111
import { MotionPlugin } from '@vueuse/motion'
1212
import { createPinia } from 'pinia'
1313
import { setupLayouts } from 'virtual:generated-layouts'
@@ -29,7 +29,7 @@ import './styles/main.css'
2929
import 'uno.css'
3030

3131
configureAnalyticsAdapter(async (options) => {
32-
const { createPosthogAdapter } = await import('@proj-airi/stage-ui/stores/analytics/posthog')
32+
const { createPosthogAdapter } = await import('@proj-airi/stage-ui/libs/analytics/posthog')
3333
return createPosthogAdapter(options)
3434
})
3535

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { registerPlugin } from '@capacitor/core'
2+
3+
interface MicrophonePermissionState {
4+
granted: boolean
5+
}
6+
7+
interface MicrophonePermissionPlugin {
8+
checkPermission: () => Promise<MicrophonePermissionState>
9+
}
10+
11+
/** Reads Android's native microphone permission state without triggering a permission request. */
12+
export const MicrophonePermission = registerPlugin<MicrophonePermissionPlugin>('MicrophonePermission')

0 commit comments

Comments
 (0)