A Tauri v2 plugin that provides a unified JavaScript API for camera access across Android, iOS and desktop. It supports live preview, photo capture, video recording (with audio), camera controls (front/back, flash, zoom), camera introspection (list the available cameras and query their capabilities), saving to the photo gallery, and camera permission management.
| Platform | Supported |
|---|---|
| Android | ✓ |
| iOS | ✓ |
| macOS | ✓ (via getUserMedia) |
| Windows | ✓ (via getUserMedia) |
| Linux | ✓ (via getUserMedia) |
On desktop the camera is driven by the webview through
navigator.mediaDevices.getUserMediaplus<video>/<canvas>. On mobile it uses a native preview (CameraX on Android, AVFoundation on iOS) for full control and quality.
Development note (AI assistance). This plugin is being developed with the assistance of AI tools. AI may have generated parts of the code, tests and documentation. Every change is reviewed and verified by maintainers (automated tests and CI) before release, but we disclose this for transparency. Please report any issue you find.
Add the Rust crate to src-tauri/Cargo.toml:
[dependencies]
tauri-plugin-camera-kessdev = "0.2.0-beta.0" # beta
# or from a git/path source:
# tauri-plugin-camera-kessdev = { path = "../tauri-plugin-camera" }Add the JavaScript bindings:
npm add tauri-plugin-camera-kessdev
# pnpm add tauri-plugin-camera-kessdev
# yarn add tauri-plugin-camera-kessdevIn src-tauri/src/lib.rs:
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_camera_kessdev::init())
.run(tauri::generate_context!())
.expect("error while running tauri application");
}Add the default permission set to src-tauri/capabilities/default.json:
{
"permissions": [
"core:default",
"camera-kessdev:default"
]
}To allow only specific commands, use the granular permissions instead of
camera-kessdev:default (e.g. camera-kessdev:allow-capture,
camera-kessdev:allow-start-recording, camera-kessdev:allow-check-permissions, …).
The full list is generated under permissions/autogenerated/commands/ as
allow-<command> / deny-<command>.
The plugin's AndroidManifest.xml declares android.permission.CAMERA and
android.permission.RECORD_AUDIO (for video audio). The Kotlin plugin registers them
through the @TauriPlugin annotation, so checkPermissions / requestPermissions work
out of the box. No further manifest edits are required.
Add the following usage descriptions to the app Info.plist
(src-tauri/Info.plist):
<key>NSCameraUsageDescription</key>
<string>This app uses the camera to capture photos and videos.</string>
<key>NSMicrophoneUsageDescription</key>
<string>This app uses the microphone to record audio in videos.</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>This app saves captured photos to your photo library.</string>NSMicrophoneUsageDescription is only required if you use video recording,
NSPhotoLibraryAddUsageDescription only if you use saveToGallery.
Desktop uses the browser's getUserMedia. The plugin installs a webview permission
handler on Windows (WebView2 PermissionRequested) and Linux (webkitgtk
permission-request) that allows camera and microphone, so startPreview() /
requestPermissions() work without extra configuration. The webview asks the plugin,
and the real gate is the operating system:
- Windows: Settings → Privacy & security → Camera → turn on camera access and "Let desktop apps access your camera".
- Linux: the desktop portal (e.g.
xdg-desktop-portal) governs camera access.
Maintenance note: the Windows handler uses the raw
webview2-comAPI, whose version must match the onewrylinks against throughtauri. When bumpingtauri/wry, bump the[target.'cfg(target_os = "windows")'.dependencies]webview2-comversion inCargo.tomlto the same minor line as wry; otherwise the build fails with anE0277trait-bound error. Theverify-windowsCI job guards this.
On macOS, WKWebView additionally requires NSCameraUsageDescription and
NSMicrophoneUsageDescription in the Info.plist (the same keys above).
import {
startPreview,
stopPreview,
capture,
startRecording,
stopRecording,
flipCamera,
setFlash,
setZoom,
getZoom,
getAvailableCameras,
getCapabilities,
saveToGallery,
checkPermissions,
requestPermissions,
openAppSettings,
getPreviewStream,
convertFileSrc
} from 'tauri-plugin-camera-kessdev'// windowed: true keeps the webview transparent so your UI renders over the camera.
await startPreview({ camera: 'back', windowed: true })
// Or pick a specific lens by id (from getAvailableCameras()); cameraId takes precedence.
const cameras = await getAvailableCameras()
await startPreview({ cameraId: cameras[0].id })
// Desktop: attach the stream to a <video> element.
const stream = await getPreviewStream()
if (stream) videoEl.srcObject = stream
await stopPreview()const photo = await capture({ flash: 'auto' })
// photo: { path, width, height, orientation, format, thumbnail? } (mobile)
// { dataUrl, width, height, orientation, format } (desktop)
// Mobile: display the captured file.
imageEl.src = convertFileSrc(photo.path)Configurable (mobile and desktop):
await capture({
resolution: { width: 1920, height: 1080 }, // nearest supported size (best-effort)
aspectRatio: '16:9', // '4:3' | '16:9' | '1:1' | ...
format: 'jpeg', // 'jpeg' | 'png' | 'heif' (mobile jpeg; desktop jpeg/png)
quality: 0.9 // 0..1 for lossy formats
})await startRecording()
// ... user records ...
const video = await stopRecording()
// video: { path, duration } (mobile) | { duration, blob, url } (desktop)
// Optionally request a base64 JPEG thumbnail of the recording:
const withThumbnail = await stopRecording({ thumbnail: true })
// withThumbnail.thumbnail: string | undefined
// Configure quality/fps/bitrate (mobile and desktop):
await startRecording({ quality: 'fhd', fps: 30, bitrate: 2_000_000 }) // quality: 'sd' | 'hd' | 'fhd' | 'uhd'On desktop the preview stays alive during recording: the recording reuses the
preview's video track (only the microphone is acquired separately), so the
<video> element bound to getPreviewStream() keeps playing. The recording
mime type is chosen from the browser's supported types (WebM or MP4).
By default the desktop camera runs in the webview (getUserMedia / MediaRecorder).
Compiling the plugin with the optional Cargo feature native-desktop (GStreamer,
cross-platform) makes the plugin own the camera natively: it captures once and fans out to a
live preview (JPEG frames delivered to your app) and a native encoder — so the
UI is never blocked and stopRecording() does not freeze the window (the WebKitGTK
problem). Query availability with getCapabilities().supportsNativeRecording.
import type { Channel } from '@tauri-apps/api/core'
import { startPreview, startRecording, stopRecording, type PreviewFrame } from 'tauri-plugin-camera-kessdev'
const frames = new Channel<PreviewFrame>()
frames.onmessage = ({ data }) => drawToCanvas(data) // base64 JPEG
await startPreview({ onFrame: frames })
await startRecording()
// ... user records ...
const video = await stopRecording() // { path, duration }- Enabling the feature requires GStreamer dev libraries for your platform (on Linux,
libgstreamer1.0-devandlibgstreamer-plugins-base1.0-dev) plus runtime plugins for the source (pipewiresrc/v4l2src,mfvideosrc/ksvideosrc,avfvideosrc),x264enc,mp4muxand AAC; on Windows/macOS install the GStreamer distribution. - With the native backend active,
getPreviewStream()returnsnull(there is no webviewMediaStream); render the frames in a<canvas>instead. capture()returns the most recent native preview frame as a base64 data URL (Photo.dataUrl/Photo.thumbnail).
Pass { thumbnail: true } to stopRecording() to include a base64-encoded JPEG
poster frame in the Video result. It is opt-in because it costs a frame decode
and encode. When it cannot be generated the field is omitted (the recording still
succeeds).
- Android: extracted with
MediaMetadataRetrieveron the recording executor. - iOS: extracted with
AVAssetImageGeneratorat ~0.1 s (avoids a black first frame). - Desktop: captured from the live preview stream at stop time.
Pass an onProgress channel to startRecording() to receive live telemetry while
the recording is in progress. The channel is optional: omit it and recording
behaves exactly as before.
import { Channel } from '@tauri-apps/api/core'
import { startRecording, stopRecording, type RecordingProgress } from 'tauri-plugin-camera-kessdev'
const progress = new Channel<RecordingProgress>()
progress.onmessage = (p) => {
// p: { duration, bytesRecorded?, bitrate? }
console.log(`${p.duration.toFixed(1)}s`, p.bytesRecorded, 'bytes')
}
await startRecording({ onProgress: progress })
// ... user records ...
const video = await stopRecording()RecordingProgress fields:
| Field | Type | Description |
|---|---|---|
duration |
number |
Elapsed recording time in seconds. Always present. |
bytesRecorded |
number? |
Bytes written so far, when the platform reports it. |
bitrate |
number? |
Average bitrate in bits per second, when derivable. |
Platform notes:
- Android: telemetry is derived from CameraX
VideoRecordEvent.Status, emitted at roughly 1 Hz natively.bytesRecordedis accurate. - iOS: telemetry is sampled from
AVCaptureMovieFileOutputevery 500 ms.bytesRecordedis omitted untilrecordedFileSizebecomes available, so early messages may only carryduration. - Desktop:
durationis computed fromperformance.now()andbytesRecordedfromMediaRecorderdata chunks. BecauseMediaRecorderonly emits a chunk every 1000 ms,bytesRecordedcan lag by up to one second.
Messages are throttled to about 4 per second and stop as soon as the recording
ends. Telemetry is never emitted after stopRecording() resolves.
await flipCamera()
await setFlash('on') // 'on' | 'off' | 'auto'
await setZoom(2.0)
// Live state (mobile):
const zoom = await getZoom() // numberOn desktop getZoom() rejects with a descriptive error (consistent with
setZoom).
Torch, tap-to-focus, exposure and white balance (mobile; desktop throws):
await setTorch(true) // continuous light (independent from capture flash)
await setFocusPoint(0.5, 0.5) // normalized 0..1, top-left origin
await setFocusMode('continuous') // 'auto' | 'continuous' | 'locked'
await setExposureCompensation(1.5) // EV, clamped to getCapabilities().exposureRange
await setExposureMode('locked') // 'auto' | 'locked'
await setWhiteBalance({ mode: 'locked', temperatureK: 5000 }) // Kelvin; 'auto' | 'locked'Details:
- Torch is independent from the capture flash (
setFlash); queryhasTorch. - Focus uses normalized coordinates (0..1, top-left origin); query
supportsFocus/focusModes. - Exposure:
evis clamped toexposureRange;exposureModesis['auto', 'locked']. - White balance is uniform across platforms:
mode: 'auto' | 'locked'. Inlocked,temperatureK(Kelvin) is applied where the hardware supports it (supportsWhiteBalanceTemperature); when unsupported, the plugin locks the current gains (or rejects) instead of inventing a temperature.autouses the platform's continuous AWB.
On desktop all of these controls reject with a descriptive error. Query the
supported values with getCapabilities() first: hasTorch,
supportsFocus/focusModes, exposureRange/exposureModes,
whiteBalanceModes/supportsWhiteBalanceTemperature.
Use getAvailableCameras() to list the cameras (physical/logical lenses) and
getCapabilities() to know what a camera can do, before trying:
const cameras = await getAvailableCameras()
// [{ id: '0', facing: 'back', lens: 'wide' }, { id: '1', facing: 'front' }, ...]
// No argument: the active camera, or the default (back) camera without a preview.
const caps = await getCapabilities(cameras[0].id)
// { cameraId, hasFlash, hasTorch, zoomRange?, supportsFocus, focusModes?,
// exposureRange?, exposureModes?, whiteBalanceModes?, supportsWhiteBalanceTemperature?,
// photoResolutions?, photoAspects?, videoQualities?, videoFps?, photoFormats?, supportsPause }The plugin follows one rule to avoid ambiguity:
- What the hardware can do ->
getCapabilities()(declarative). - What a value is right now -> a dedicated live getter (e.g.
getZoom()).
Both work without an active preview on mobile. On desktop getAvailableCameras()
works cold (labels may stay empty until camera permission has been granted once),
while the hardware capabilities zoomRange, hasTorch and focusModes are
omitted until a preview/recording stream is active; hasFlash is always false
and supportsPause is true.
await saveToGallery(photo.path) // image
await saveToGallery(video.path) // video
await saveToGallery(media.path, { type: 'video' }) // forced type overrideMobile only. The media type is inferred from the file extension (mp4, mov, m4v,
webm, 3gp, mkv are treated as video); pass { type } to force it.
const state = await checkPermissions() // 'granted' | 'denied' | 'prompt'
await requestPermissions()
// Mobile: open the OS settings screen (e.g. after a permanent denial).
await openAppSettings()- Mobile writes the capture to a temporary file in native code and returns its path.
Use
convertFileSrc(path)to display it, read it with thefsplugin, or upload it withfetch/thehttpplugin. - Desktop returns a base64 data URL and a
Blob(no native file system write).
This keeps large image payloads out of the JSON IPC bridge (mobile) while staying idiomatic on the web (desktop).
The plugin ships unit tests for every layer. They are pure logic tests — no camera hardware, emulator or device is required.
| Layer | Test file | Command | Run from | Requires |
|---|---|---|---|---|
| Rust | src/models.rs (inline tests) |
cargo test |
plugin root | Rust + Cargo |
| JS | guest-js/index.test.ts |
npm test |
plugin root | Node.js 18+ + npm |
| Android | android/src/test/java/CameraPluginTest.kt |
gradle test |
android/ (or generated app) |
JDK 17+, Android SDK, Gradle |
| iOS | ios/Tests/PluginTests/PluginTests.swift |
xcodebuild test |
iOS Simulator (generated app) | macOS + Xcode |
Rust
- Rust (stable toolchain).
- Optional — to verify the crate compiles for mobile:
rustup target add aarch64-linux-android
JavaScript
- Node.js 18+ and npm (or pnpm/yarn).
Android (Kotlin)
- JDK 17 or newer.
- Android SDK with
platforms/android-36andbuild-tools. - Gradle (or the generated project's wrapper).
iOS (Swift)
- macOS with Xcode. iOS tests cannot run on Linux or Windows.
cd tauri-plugin-camera
cargo testCovers model (de)serialization and the error types. To also verify the crate compiles against the Android target:
cargo check --target aarch64-linux-androidcd tauri-plugin-camera
npm install
npm testnpm test runs Vitest; the tests cover the API signatures and the mobile/desktop
dispatch. To validate types and generate the bindings:
npm run buildThe JUnit tests cover flash-mode mapping, zoom clamping and camera direction.
Running them requires the full Tauri Android pipeline. The plugin's android/
module is a Gradle library built as part of the generated app project, and the
Gradle config (tauri.settings.gradle) is only generated during a Tauri Android
build — not by tauri android init (that only lays down the base project). The
sequence is:
cd tauri-plugin-camera/examples/tauri-app
npm install
npm run tauri android init
npm run tauri android build # generates tauri.settings.gradle and compiles
cd src-tauri/gen/android
./gradlew testRequires JDK 17+, Android SDK + NDK, and platforms/android-36 — or set
compileSdk = 35 in android/build.gradle.kts if your SDK only goes up to 35.
tauri android build builds a release APK (needs a signing key); tauri android dev
builds a debug APK but requires a connected device/emulator. Either one generates
tauri.settings.gradle, after which ./gradlew test works.
The XCTest tests cover decoding of the command options. Testing requires full Xcode — Command Line Tools is not enough, because the plugin imports UIKit, AVFoundation and Photos, which need the iOS SDK and the Simulator.
The plugin is iOS-only, so it cannot be built with swift test (that targets
macOS, where UIKit is unavailable, and the Tauri package referenced by
ios/Package.swift is only created by the Tauri iOS setup). Validate the Swift
code by building the iOS app:
cd tauri-plugin-camera/examples/tauri-app
npm install
npm run tauri ios init
npm run tauri ios build # compiles the Swift plugin for iOSTo run the XCTest suite itself, use xcodebuild test against an iOS Simulator
destination from the generated Xcode project (src-tauri/gen/apple).
| Function | Mobile | Desktop |
|---|---|---|
startPreview(options?) |
✓ native preview | ✓ getUserMedia |
stopPreview() |
✓ | ✓ |
capture(options?) |
✓ ImageCapture / AVCapturePhotoOutput |
✓ canvas |
flipCamera() |
✓ | ✗ (throws) |
setFlash(mode) |
✓ | ✗ (throws) |
setZoom(factor) |
✓ | ✗ (throws) |
getZoom() |
✓ | ✗ (throws) |
setTorch(on) |
✓ | ✗ (throws) |
setFocusPoint(x, y) |
✓ | ✗ (throws) |
setFocusMode(mode) |
✓ | ✗ (throws) |
setExposureCompensation(ev) |
✓ | ✗ (throws) |
setExposureMode(mode) |
✓ | ✗ (throws) |
setWhiteBalance(options) |
✓ | ✗ (throws) |
getAvailableCameras() |
✓ | ✓ |
getCapabilities(cameraId?) |
✓ | ✓ (partial) |
startRecording(options?) |
✓ VideoCapture / AVCaptureMovieFileOutput |
✓ MediaRecorder |
stopRecording(options?) |
✓ | ✓ |
RecordingProgress telemetry (onProgress channel) |
✓ | ✓ |
Video.thumbnail (stopRecording({ thumbnail: true })) |
✓ | ✓ |
saveToGallery(path, options?) |
✓ image & video | ✗ (throws) |
checkPermissions() |
✓ | ✓ |
requestPermissions() |
✓ | ✓ |
openAppSettings() |
✓ | ✗ (throws) |
getPreviewStream() |
null |
MediaStream |
type CameraDirection = 'front' | 'back'
type FlashMode = 'on' | 'off' | 'auto'
type CameraFacing = 'front' | 'back' | 'external'
type CameraLens = 'wide' | 'ultraWide' | 'telephoto' | (string & {})
type FocusMode = 'auto' | 'continuous' | 'locked'
type ExposureMode = 'auto' | 'locked'
type WhiteBalanceMode = 'auto' | 'locked'
type PhotoFormat = 'jpeg' | 'png' | 'heif'
type VideoQuality = 'sd' | 'hd' | 'fhd' | 'uhd'
type PermissionState = 'granted' | 'denied' | 'prompt' // re-exported from @tauri-apps/api/core
interface StartPreviewOptions { camera?: CameraDirection; cameraId?: string; windowed?: boolean; onFrame?: Channel<PreviewFrame> }
interface PreviewFrame { data: string; width: number; height: number }
interface CaptureOptions {
flash?: FlashMode
resolution?: Resolution
aspectRatio?: string
format?: PhotoFormat
quality?: number
}
interface Resolution { width: number; height: number }
interface ExposureRange { minEv: number; maxEv: number }
interface WhiteBalanceOptions { mode: WhiteBalanceMode; temperatureK?: number }
interface Photo {
path?: string // mobile: absolute path to the captured file
dataUrl?: string // desktop: base64 data URL
blob?: Blob // desktop
width: number
height: number
orientation: number // EXIF orientation
format: string
thumbnail?: string // base64 JPEG thumbnail (mobile; desktop reuses dataUrl)
}
interface Video {
path?: string // mobile
duration: number
blob?: Blob // desktop
url?: string // desktop object URL
thumbnail?: string // base64 JPEG when requested via stopRecording({ thumbnail: true })
}
interface ZoomRange { min: number; max: number }
interface CameraInfo {
id: string // native, session-stable identifier
facing?: CameraFacing
lens?: CameraLens
label?: string
}
interface CameraCapabilities {
cameraId?: string
hasFlash: boolean
hasTorch: boolean
zoomRange?: ZoomRange
supportsFocus: boolean
focusModes?: string[]
exposureRange?: ExposureRange
exposureModes?: ExposureMode[]
whiteBalanceModes?: WhiteBalanceMode[]
supportsWhiteBalanceTemperature?: boolean
supportsNativeRecording?: boolean
photoResolutions?: Resolution[]
photoAspects?: string[]
videoQualities?: VideoQuality[]
videoFps?: number[]
photoFormats?: string[]
supportsPause: boolean
}
interface RecordingProgress { duration: number; bytesRecorded?: number; bitrate?: number }
interface StartRecordingOptions {
onProgress?: Channel<RecordingProgress>
quality?: VideoQuality
fps?: number
bitrate?: number
}
interface StopRecordingOptions { thumbnail?: boolean }
interface SaveToGalleryOptions { type?: 'image' | 'video' }