Skip to content

About

Camera plugin for Tauri v2: capture photos, record video, and control the camera on mobile and desktop.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Tauri Plugin Camera

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.getUserMedia plus <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.

Install

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-kessdev

Register

In 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");
}

Permissions

Capabilities (Tauri v2)

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>.

Android

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.

iOS

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

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-com API, whose version must match the one wry links against through tauri. When bumping tauri/wry, bump the [target.'cfg(target_os = "windows")'.dependencies] webview2-com version in Cargo.toml to the same minor line as wry; otherwise the build fails with an E0277 trait-bound error. The verify-windows CI job guards this.

On macOS, WKWebView additionally requires NSCameraUsageDescription and NSMicrophoneUsageDescription in the Info.plist (the same keys above).

Usage

import {
  startPreview,
  stopPreview,
  capture,
  startRecording,
  stopRecording,
  flipCamera,
  setFlash,
  setZoom,
  getZoom,
  getAvailableCameras,
  getCapabilities,
  saveToGallery,
  checkPermissions,
  requestPermissions,
  openAppSettings,
  getPreviewStream,
  convertFileSrc
} from 'tauri-plugin-camera-kessdev'

Preview

// 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()

Photo capture

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
})

Video recording (with audio)

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).

Desktop native backend (feature native-desktop)

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-dev and libgstreamer-plugins-base1.0-dev) plus runtime plugins for the source (pipewiresrc/v4l2src, mfvideosrc/ksvideosrc, avfvideosrc), x264enc, mp4mux and AAC; on Windows/macOS install the GStreamer distribution.
  • With the native backend active, getPreviewStream() returns null (there is no webview MediaStream); render the frames in a <canvas> instead.
  • capture() returns the most recent native preview frame as a base64 data URL (Photo.dataUrl / Photo.thumbnail).

Video 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 MediaMetadataRetriever on the recording executor.
  • iOS: extracted with AVAssetImageGenerator at ~0.1 s (avoids a black first frame).
  • Desktop: captured from the live preview stream at stop time.

Recording progress (telemetry)

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. bytesRecorded is accurate.
  • iOS: telemetry is sampled from AVCaptureMovieFileOutput every 500 ms. bytesRecorded is omitted until recordedFileSize becomes available, so early messages may only carry duration.
  • Desktop: duration is computed from performance.now() and bytesRecorded from MediaRecorder data chunks. Because MediaRecorder only emits a chunk every 1000 ms, bytesRecorded can 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.

Camera controls

await flipCamera()
await setFlash('on') // 'on' | 'off' | 'auto'
await setZoom(2.0)

// Live state (mobile):
const zoom = await getZoom() // number

On desktop getZoom() rejects with a descriptive error (consistent with setZoom).

Fine controls

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); query hasTorch.
  • Focus uses normalized coordinates (0..1, top-left origin); query supportsFocus / focusModes.
  • Exposure: ev is clamped to exposureRange; exposureModes is ['auto', 'locked'].
  • White balance is uniform across platforms: mode: 'auto' | 'locked'. In locked, 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. auto uses 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.

Camera introspection

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.

Gallery

await saveToGallery(photo.path)                 // image
await saveToGallery(video.path)                 // video
await saveToGallery(media.path, { type: 'video' }) // forced type override

Mobile 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.

Permissions

const state = await checkPermissions()   // 'granted' | 'denied' | 'prompt'
await requestPermissions()

// Mobile: open the OS settings screen (e.g. after a permanent denial).
await openAppSettings()

Image return pattern

  • Mobile writes the capture to a temporary file in native code and returns its path. Use convertFileSrc(path) to display it, read it with the fs plugin, or upload it with fetch/the http plugin.
  • 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).

Testing

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

Prerequisites

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-36 and build-tools.
  • Gradle (or the generated project's wrapper).

iOS (Swift)

  • macOS with Xcode. iOS tests cannot run on Linux or Windows.

Rust

cd tauri-plugin-camera
cargo test

Covers model (de)serialization and the error types. To also verify the crate compiles against the Android target:

cargo check --target aarch64-linux-android

JavaScript

cd tauri-plugin-camera
npm install
npm test

npm test runs Vitest; the tests cover the API signatures and the mobile/desktop dispatch. To validate types and generate the bindings:

npm run build

Android (Kotlin)

The 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 test

Requires 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.

iOS (Swift)

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 iOS

To run the XCTest suite itself, use xcodebuild test against an iOS Simulator destination from the generated Xcode project (src-tauri/gen/apple).

Full API

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

Types

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' }

About

Camera plugin for Tauri v2: capture photos, record video, and control the camera on mobile and desktop.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages