feat(stage-tamagotchi,electron-screen-capture,i18n): check for screen capture permissions

This commit is contained in:
Makito
2026-01-13 01:13:16 +09:00
parent a2b3469ba0
commit 969d53f8a1
9 changed files with 328 additions and 133 deletions
@@ -0,0 +1,111 @@
<script setup lang="ts">
import type { SourcesOptions } from 'electron'
import { useElectronScreenCapture } from '@proj-airi/electron-screen-capture/vue'
import { Button } from '@proj-airi/ui'
import { useWindowFocus } from '@vueuse/core'
import { DialogContent, DialogDescription, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } from 'reka-ui'
import { onMounted, ref, toRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
const props = defineProps<{
sourcesOptions: SourcesOptions
}>()
const sourcesOptions = toRef(props, 'sourcesOptions')
const hasPermissions = ref(false)
const showDialog = ref(false)
const { t } = useI18n()
const {
getSources,
setSource,
selectWithSource,
checkMacOSPermission,
requestMacOSPermission,
} = useElectronScreenCapture(window.electron.ipcRenderer, sourcesOptions)
const focused = useWindowFocus()
async function checkPermissions() {
if (window.platform === 'darwin') {
const status = await checkMacOSPermission()
hasPermissions.value = status === 'granted'
}
else {
hasPermissions.value = true
}
if (!hasPermissions.value) {
showDialog.value = true
}
}
async function requestPermission() {
if (window.platform === 'darwin') {
await requestMacOSPermission()
}
}
onMounted(async () => {
await checkPermissions()
})
watch(focused, async (isFocused) => {
if (isFocused) {
await checkPermissions()
}
})
</script>
<template>
<slot
v-bind="{
getSources,
setSource,
selectWithSource,
hasPermissions,
checkPermissions,
requestPermission,
}"
/>
<DialogRoot :open="showDialog">
<DialogPortal>
<DialogOverlay class="fixed inset-0 z-9999 bg-black/50 backdrop-blur-sm data-[state=closed]:animate-fadeOut data-[state=open]:animate-fadeIn" />
<DialogContent flex="~ col items-start gap-4" class="fixed left-1/2 top-1/2 z-9999 max-h-full max-w-2xl w-[92dvw] transform overflow-y-scroll rounded-2xl bg-white p-6 shadow-xl outline-none backdrop-blur-md scrollbar-none -translate-x-1/2 -translate-y-1/2 data-[state=closed]:animate-contentHide data-[state=open]:animate-contentShow dark:bg-neutral-900">
<DialogTitle class="m-0 text-lg font-semibold">
{{ t('tamagotchi.settings.screen-capture.permissions-prompt.title') }}
</DialogTitle>
<DialogDescription>
{{ t('tamagotchi.settings.screen-capture.permissions-prompt.description') }}
<ol mt-4 list-decimal pl-5 text-sm>
<li mb-1>
{{ t('tamagotchi.settings.screen-capture.permissions-prompt.instructions.step-1') }}
</li>
<li>
{{ t('tamagotchi.settings.screen-capture.permissions-prompt.instructions.step-2') }}
<br>
<span class="text-neutral-500">
{{ t('tamagotchi.settings.screen-capture.permissions-prompt.instructions.step-2-note') }}
</span>
</li>
</ol>
</DialogDescription>
<div flex="~ row gap-2 mt-4 justify-end" w-full>
<Button
variant="secondary"
@click="showDialog = false"
>
{{ t('tamagotchi.settings.screen-capture.permissions-prompt.dismiss') }}
</Button>
<Button @click="requestMacOSPermission()">
{{ t('tamagotchi.settings.screen-capture.permissions-prompt.open-preferences') }}
</Button>
</div>
</DialogContent>
</DialogPortal>
</DialogRoot>
</template>
@@ -1,29 +1,39 @@
<script setup lang="ts"> <script setup lang="ts">
import type { SerializableDesktopCapturerSource } from '@proj-airi/electron-screen-capture' import type { SerializableDesktopCapturerSource } from '@proj-airi/electron-screen-capture'
import type { SourcesOptions } from 'electron'
import { useElectronScreenCapture } from '@proj-airi/electron-screen-capture/vue' import { useElectronScreenCapture } from '@proj-airi/electron-screen-capture/vue'
import { Button } from '@proj-airi/ui' import { Button } from '@proj-airi/ui'
import { onBeforeUnmount, onMounted, ref } from 'vue' import { onBeforeUnmount, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
const sources = ref<ScreenCaptureSource[]>([]) import WithScreenCapture from '../../components/WithScreenCapture.vue'
const isRefetching = ref(false)
const activeStreams = ref<MediaStream[]>([])
interface ScreenCaptureSource extends SerializableDesktopCapturerSource { interface ScreenCaptureSource extends SerializableDesktopCapturerSource {
appIconURL?: string appIconURL?: string
thumbnailURL?: string thumbnailURL?: string
} }
const { getSources, selectWithSource } = useElectronScreenCapture(window.electron.ipcRenderer, { const sources = ref<ScreenCaptureSource[]>([])
const isRefetching = ref(false)
const activeStreams = ref<MediaStream[]>([])
const sourcesOptions = ref<SourcesOptions>({
types: ['screen', 'window'], types: ['screen', 'window'],
fetchWindowIcons: true, fetchWindowIcons: true,
}) })
const { t } = useI18n()
const {
getSources,
selectWithSource,
} = useElectronScreenCapture(window.electron.ipcRenderer, sourcesOptions)
function toLocalArrayBuffer(bytes: Uint8Array) { function toLocalArrayBuffer(bytes: Uint8Array) {
if (bytes.buffer instanceof SharedArrayBuffer) { if (typeof SharedArrayBuffer !== 'undefined' && bytes.buffer instanceof SharedArrayBuffer) {
return bytes.slice().buffer return bytes.slice().buffer
} }
return bytes.buffer return bytes.buffer as ArrayBuffer
} }
function toObjectUrl(bytes: Uint8Array, mime: string) { function toObjectUrl(bytes: Uint8Array, mime: string) {
@@ -80,6 +90,9 @@ async function refetchSources() {
thumbnailURL: source.thumbnail && source.thumbnail.length > 0 ? toObjectUrl(source.thumbnail, 'image/jpeg') : undefined, thumbnailURL: source.thumbnail && source.thumbnail.length > 0 ? toObjectUrl(source.thumbnail, 'image/jpeg') : undefined,
})) }))
} }
catch (err) {
console.error('Error fetching sources:', err)
}
finally { finally {
isRefetching.value = false isRefetching.value = false
} }
@@ -100,133 +113,146 @@ onBeforeUnmount(() => {
</script> </script>
<template> <template>
<div <WithScreenCapture :sources-options="sourcesOptions">
flex="~ col gap-4 items-start" w-full <template #default="{ hasPermissions, requestPermission }">
text="neutral-500 dark:neutral-400"
>
<div
v-if="activeStreams.length > 0"
bg="primary-300/10"
b="2 solid primary-400/70"
w-full overflow-hidden rounded-2xl p-3
flex="~ col gap-2"
>
<div flex="~ row items-center gap-2">
<div class="i-solar:videocamera-record-line-duotone" />
<div>Capturing</div>
</div>
<div <div
flex="~ row items-center gap-3" v-if="hasPermissions"
w-full overflow-x-auto flex="~ col gap-4 items-start" w-full
text="neutral-500 dark:neutral-400"
> >
<div <div
v-for="stream in activeStreams" :key="stream.id" v-if="activeStreams.length > 0"
relative overflow-hidden rounded-lg bg="primary-300/10"
b="2 solid primary-400/70"
w-full overflow-hidden rounded-2xl p-3
flex="~ col gap-2"
> >
<div <div flex="~ row items-center gap-2">
flex="~ col items-center justify-center gap-1" <div class="i-solar:videocamera-record-line-duotone" />
absolute right-0 top-0 z-10 h-full w-full cursor-pointer <div>Capturing</div>
rounded-lg op-0 backdrop-blur-sm hover:op-100
transition="all duration-200"
text="light"
bg="black/30"
@click="stopStream(stream)"
>
<div class="i-solar:stop-line-duotone" />
<div text-sm>
Stop
</div>
</div>
<video
autoplay
muted
playsinline
:srcObject="stream"
h-140px
w-auto
/>
</div>
</div>
</div>
<div
flex="~ col gap-3"
w-full pb-6
>
<div flex="~ row items-center justify-between" w-full>
<div>{{ sources.length }} source(s)</div>
<Button
:label="isRefetching ? 'Refetching...' : 'Refetch'"
icon="i-solar:refresh-line-duotone"
size="sm"
:disabled="isRefetching"
@click="refetchSources()"
/>
</div>
<div grid="~ cols-1 sm:cols-2 md:cols-3 lg:cols-4 xl:cols-5 gap-3">
<div
v-for="source in sources"
:key="source.id"
flex="~ col justify-between gap-3"
w-full cursor-pointer rounded-2xl p-3
transition="all duration-200"
border="2 solid neutral-200/60 dark:neutral-800/10 hover:primary-400/70"
@click="startCapture(source)"
>
<div flex="~ col items-start w-full">
<div flex="~ row items-center gap-1">
<div class="h-16px w-16px">
<img
v-if="source.appIconURL"
:src="source.appIconURL"
:alt="source.id.startsWith('screen:') ? 'Screen Icon' : 'Window Icon'"
class="h-full w-full shrink-0"
>
<div
v-else-if="source.id.startsWith('screen:')"
h-full w-full
class="i-solar:screencast-2-line-duotone"
/>
<div
v-else
h-full w-full
class="i-solar:window-frame-line-duotone"
/>
</div>
<div text-sm>
{{ source.id.startsWith('screen:') ? 'Screen' : 'Window' }}
</div>
</div>
<div text-ellipsis break-all>
{{ source.name }}
</div>
<div text-sm font-mono text="neutral-400 dark:neutral-600">
{{ source.id }}
</div>
</div> </div>
<div <div
h-200px w-full overflow-hidden rounded-2xl bg-black flex="~ row items-center gap-3"
flex="~ items-center justify-center shrink-0" w-full overflow-x-auto
> >
<img
v-if="source.thumbnailURL"
:src="source.thumbnailURL"
alt="Thumbnail"
h-full
w-full object-contain
>
<div <div
v-else v-for="stream in activeStreams" :key="stream.id"
class="i-solar:forbidden-circle-line-duotone" relative overflow-hidden rounded-lg
h-10 w-10 bg-light >
<div
flex="~ col items-center justify-center gap-1"
absolute right-0 top-0 z-10 h-full w-full cursor-pointer
rounded-lg op-0 backdrop-blur-sm hover:op-100
transition="all duration-200"
text="light"
bg="black/30"
@click="stopStream(stream)"
>
<div class="i-solar:stop-line-duotone" />
<div text-sm>
Stop
</div>
</div>
<video
autoplay
muted
playsinline
:srcObject="stream"
h-140px
w-auto
/>
</div>
</div>
</div>
<div
flex="~ col gap-3"
w-full pb-6
>
<div flex="~ row items-center justify-between" w-full>
<div>{{ sources.length }} source(s)</div>
<Button
:label="isRefetching ? 'Refetching...' : 'Refetch'"
icon="i-solar:refresh-line-duotone"
size="sm"
:disabled="isRefetching"
@click="refetchSources()"
/> />
</div> </div>
<div grid="~ cols-1 sm:cols-2 md:cols-3 lg:cols-4 xl:cols-5 gap-3">
<div
v-for="source in sources"
:key="source.id"
flex="~ col justify-between gap-3"
w-full cursor-pointer rounded-2xl p-3
transition="all duration-200"
border="2 solid neutral-200/60 dark:neutral-800/10 hover:primary-400/70"
@click="startCapture(source)"
>
<div flex="~ col items-start w-full">
<div flex="~ row items-center gap-1">
<div class="h-16px w-16px">
<img
v-if="source.appIconURL"
:src="source.appIconURL"
:alt="source.id.startsWith('screen:') ? 'Screen Icon' : 'Window Icon'"
class="h-full w-full shrink-0"
>
<div
v-else-if="source.id.startsWith('screen:')"
h-full w-full
class="i-solar:screencast-2-line-duotone"
/>
<div
v-else
h-full w-full
class="i-solar:window-frame-line-duotone"
/>
</div>
<div text-sm>
{{ source.id.startsWith('screen:') ? 'Screen' : 'Window' }}
</div>
</div>
<div text-ellipsis break-all>
{{ source.name }}
</div>
<div text-sm font-mono text="neutral-400 dark:neutral-600">
{{ source.id }}
</div>
</div>
<div
h-200px w-full overflow-hidden rounded-2xl bg-black
flex="~ items-center justify-center shrink-0"
>
<img
v-if="source.thumbnailURL"
:src="source.thumbnailURL"
alt="Thumbnail"
h-full
w-full object-contain
>
<div
v-else
class="i-solar:forbidden-circle-line-duotone"
h-10 w-10 bg-light
/>
</div>
</div>
</div>
</div> </div>
</div> </div>
</div> <div v-else flex="~ col gap-4 items-center justify-center" h-full p-6>
</div> <div>
{{ t('tamagotchi.settings.screen-capture.permissions-prompt.description') }}
</div>
<Button @click="requestPermission()">
{{ t('tamagotchi.settings.screen-capture.permissions-prompt.open-preferences') }}
</Button>
</div>
</template>
</WithScreenCapture>
</template> </template>
<route lang="yaml"> <route lang="yaml">
+1
View File
@@ -273,6 +273,7 @@ words:
- sumimakito - sumimakito
- supergroup - supergroup
- superjson - superjson
- systempreferences
- tamagotchi - tamagotchi
- tauri - tauri
- taze - taze
@@ -1,4 +1,4 @@
import type { DesktopCapturerSource, SourcesOptions } from 'electron' import type { DesktopCapturerSource, SourcesOptions, systemPreferences } from 'electron'
import { defineInvokeEventa } from '@moeru/eventa' import { defineInvokeEventa } from '@moeru/eventa'
@@ -19,5 +19,8 @@ export interface ScreenCaptureSetSourceRequest {
} }
export const screenCaptureGetSources = defineInvokeEventa<SerializableDesktopCapturerSource[], SourcesOptions>('eventa:invoke:electron:screen-capture:get-sources') export const screenCaptureGetSources = defineInvokeEventa<SerializableDesktopCapturerSource[], SourcesOptions>('eventa:invoke:electron:screen-capture:get-sources')
export const screenCaptureSetSourceEx = defineInvokeEventa<any, ScreenCaptureSetSourceRequest>('eventa:invoke:electron:screen-capture:set-source') export const screenCaptureSetSourceEx = defineInvokeEventa<string, ScreenCaptureSetSourceRequest>('eventa:invoke:electron:screen-capture:set-source')
export const screenCaptureResetSource = defineInvokeEventa<any, string>('eventa:invoke:electron:screen-capture:reset-source') export const screenCaptureResetSource = defineInvokeEventa<void, string>('eventa:invoke:electron:screen-capture:reset-source')
export const screenCaptureCheckMacOSPermission = defineInvokeEventa<ReturnType<typeof systemPreferences.getMediaAccessStatus>, never>('eventa:invoke:electron:screen-capture:check-macos-permission')
export const screenCaptureRequestMacOSPermission = defineInvokeEventa<void, never>('eventa:invoke:electron:screen-capture:request-macos-permission')
@@ -11,8 +11,8 @@ import { Mutex, withTimeout } from 'async-mutex'
import { app, desktopCapturer, ipcMain, session as sessionModule } from 'electron' import { app, desktopCapturer, ipcMain, session as sessionModule } from 'electron'
import { nanoid } from 'nanoid' import { nanoid } from 'nanoid'
import { screenCaptureGetSources, screenCaptureResetSource, screenCaptureSetSourceEx } from '..' import { screenCaptureCheckMacOSPermission, screenCaptureGetSources, screenCaptureRequestMacOSPermission, screenCaptureResetSource, screenCaptureSetSourceEx } from '..'
import { toSerializableDesktopCapturerSource } from './utils' import { checkMacOSScreenCapturePermission, requestMacOSScreenCapturePermission, toSerializableDesktopCapturerSource } from './utils'
export const defaultSourcesOptions: SourcesOptions = { types: ['screen'] } export const defaultSourcesOptions: SourcesOptions = { types: ['screen'] }
@@ -144,6 +144,9 @@ export function initScreenCaptureForWindow(window: BrowserWindow, options?: Init
const { context } = createContext(ipcMain, window, { onlySameWindow: true }) const { context } = createContext(ipcMain, window, { onlySameWindow: true })
const session = sessionModule.defaultSession const session = sessionModule.defaultSession
defineInvokeHandler(context, screenCaptureCheckMacOSPermission, async () => checkMacOSScreenCapturePermission())
defineInvokeHandler(context, screenCaptureRequestMacOSPermission, async () => requestMacOSScreenCapturePermission())
defineInvokeHandler(context, screenCaptureGetSources, async (sourcesOptions) => { defineInvokeHandler(context, screenCaptureGetSources, async (sourcesOptions) => {
const sources = await desktopCapturer.getSources(sourcesOptions) const sources = await desktopCapturer.getSources(sourcesOptions)
return sources.map(source => toSerializableDesktopCapturerSource(source)) return sources.map(source => toSerializableDesktopCapturerSource(source))
@@ -2,6 +2,10 @@ import type { DesktopCapturerSource } from 'electron'
import type { SerializableDesktopCapturerSource } from '..' import type { SerializableDesktopCapturerSource } from '..'
import process from 'node:process'
import { shell, systemPreferences } from 'electron'
/** /**
* Serializes a DesktopCapturerSource to a format that can be sent over IPC. * Serializes a DesktopCapturerSource to a format that can be sent over IPC.
* *
@@ -24,3 +28,19 @@ export function toSerializableDesktopCapturerSource(source: DesktopCapturerSourc
thumbnail: source.thumbnail != null ? new Uint8Array(source.thumbnail.toJPEG(90).buffer) : undefined, thumbnail: source.thumbnail != null ? new Uint8Array(source.thumbnail.toJPEG(90).buffer) : undefined,
} }
} }
export function checkMacOSScreenCapturePermission(): ReturnType<typeof systemPreferences.getMediaAccessStatus> {
if (process.platform !== 'darwin') {
throw new Error('checkMacOSScreenCapturePermission is only available on macOS (darwin)')
}
return systemPreferences.getMediaAccessStatus('screen')
}
export function requestMacOSScreenCapturePermission(): void {
if (process.platform !== 'darwin') {
throw new Error('requestMacOSScreenCapturePermission is only available on macOS (darwin)')
}
shell.openExternal('x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture')
}
@@ -5,7 +5,7 @@ import type { ScreenCaptureSetSourceRequest, SerializableDesktopCapturerSource }
import { defineInvoke } from '@moeru/eventa' import { defineInvoke } from '@moeru/eventa'
import { screenCaptureGetSources, screenCaptureResetSource, screenCaptureSetSourceEx } from '.' import { screenCaptureCheckMacOSPermission, screenCaptureGetSources, screenCaptureRequestMacOSPermission, screenCaptureResetSource, screenCaptureSetSourceEx } from '.'
export interface SourceOptionsWithRequest { export interface SourceOptionsWithRequest {
sourcesOptions?: SourcesOptions sourcesOptions?: SourcesOptions
@@ -17,6 +17,9 @@ export function setupElectronScreenCapture(context: ReturnType<typeof createCont
const setSource = defineInvoke(context, screenCaptureSetSourceEx) const setSource = defineInvoke(context, screenCaptureSetSourceEx)
const resetSource = defineInvoke(context, screenCaptureResetSource) const resetSource = defineInvoke(context, screenCaptureResetSource)
const checkMacOSPermission = defineInvoke(context, screenCaptureCheckMacOSPermission)
const requestMacOSPermission = defineInvoke(context, screenCaptureRequestMacOSPermission)
async function getSources(sourcesOptions: SourcesOptions) { async function getSources(sourcesOptions: SourcesOptions) {
return invokeGetSources(sourcesOptions) return invokeGetSources(sourcesOptions)
} }
@@ -45,5 +48,12 @@ export function setupElectronScreenCapture(context: ReturnType<typeof createCont
} }
} }
return { getSources, setSource, selectWithSource, resetSource } return {
getSources,
setSource,
selectWithSource,
resetSource,
checkMacOSPermission,
requestMacOSPermission,
}
} }
@@ -6,9 +6,9 @@ import type { ScreenCaptureSetSourceRequest, SerializableDesktopCapturerSource }
import { defineInvoke } from '@moeru/eventa' import { defineInvoke } from '@moeru/eventa'
import { createContext } from '@moeru/eventa/adapters/electron/renderer' import { createContext } from '@moeru/eventa/adapters/electron/renderer'
import { toValue } from 'vue' import { toRaw, toValue } from 'vue'
import { screenCaptureGetSources, screenCaptureResetSource, screenCaptureSetSourceEx } from '..' import { screenCaptureCheckMacOSPermission, screenCaptureGetSources, screenCaptureRequestMacOSPermission, screenCaptureResetSource, screenCaptureSetSourceEx } from '..'
export function useElectronScreenCapture(ipcRenderer: IpcRenderer, sourcesOptions: MaybeRefOrGetter<SourcesOptions>) { export function useElectronScreenCapture(ipcRenderer: IpcRenderer, sourcesOptions: MaybeRefOrGetter<SourcesOptions>) {
const context = createContext(ipcRenderer).context const context = createContext(ipcRenderer).context
@@ -17,8 +17,11 @@ export function useElectronScreenCapture(ipcRenderer: IpcRenderer, sourcesOption
const setSource = defineInvoke(context, screenCaptureSetSourceEx) const setSource = defineInvoke(context, screenCaptureSetSourceEx)
const resetSource = defineInvoke(context, screenCaptureResetSource) const resetSource = defineInvoke(context, screenCaptureResetSource)
const checkMacOSPermission = defineInvoke(context, screenCaptureCheckMacOSPermission)
const requestMacOSPermission = defineInvoke(context, screenCaptureRequestMacOSPermission)
async function getSources() { async function getSources() {
return invokeGetSources(toValue(sourcesOptions)) return invokeGetSources(toRaw(toValue(sourcesOptions)))
} }
async function selectWithSource<R>( async function selectWithSource<R>(
@@ -32,7 +35,7 @@ export function useElectronScreenCapture(ipcRenderer: IpcRenderer, sourcesOption
let handle: string | undefined let handle: string | undefined
try { try {
handle = await setSource({ handle = await setSource({
options: toValue(sourcesOptions), options: toRaw(toValue(sourcesOptions)),
sourceId, sourceId,
timeout: request?.timeout, timeout: request?.timeout,
}) })
@@ -45,5 +48,12 @@ export function useElectronScreenCapture(ipcRenderer: IpcRenderer, sourcesOption
} }
} }
return { getSources, setSource, resetSource, selectWithSource } return {
getSources,
setSource,
resetSource,
selectWithSource,
checkMacOSPermission,
requestMacOSPermission,
}
} }
@@ -42,3 +42,14 @@ devtools:
description: Stress markdown parsing and rendering with heavy chat payloads description: Stress markdown parsing and rendering with heavy chat payloads
widgets-calling: widgets-calling:
title: Widget Calling title: Widget Calling
screen-capture:
permissions-prompt:
title: Screen Capture
description: To capture screens or windows, screen and system audio recording permissions are required.
open-preferences: Open System Preferences
dismiss: Dismiss
instructions:
step-1: Open "System Preferences", select "Privacy & Security" from the sidebar.
step-2: In the "Screen & System Audio Recording" and "System Audio Recording Only" sections, turn on the switches for AIRI.
step-2-note: If AIRI is not listed, please add it manually by clicking the "+" button and selecting AIRI from the Applications folder.