diff --git a/README.md b/README.md
index 1428fc5..83eae48 100644
--- a/README.md
+++ b/README.md
@@ -41,9 +41,6 @@ This project includes a [`mise.toml`](mise.toml) configuration that manages the
mise trust
mise install
-# Install Linux udev rules (one-time setup on Linux for non-root USB access)
-mise run udev-install
-
# Build & launch the desktop GUI
mise run gui
@@ -70,28 +67,18 @@ The compiled binaries will be placed in `target/release/`:
---
-## Platform Setup Instructions
+## Platform Notes
-### Linux (`udev` Rules)
+### Linux (`V4L2` & `uvcvideo`)
-On Linux, `nusb` communicates directly with the kernel `usbfs` device node (`/dev/bus/usb/BBB/DDD`) and detaches/claims only the UVC VideoControl interface while leaving the V4L2 video streaming interface (`/dev/video*`) untouched for OBS, Zoom, or browsers.
+On Linux, `insta360-link-protocol` communicates directly with the primary V4L2 video capture node (`/dev/video*`) using `UVCIOC_CTRL_QUERY` for proprietary Insta360 Extension Unit (`XU1`/`XU2`) commands and standard V4L2 `VIDIOC_G_CTRL`/`VIDIOC_S_CTRL` ioctls for Processing Unit / Camera Terminal controls, while reading USB descriptors from sysfs. Because `systemd-logind` automatically grants the active desktop user session access to `/dev/video*`, **no custom `udev` rules or `sudo` privileges are required**.
-To run the CLI, server, and GUI without `sudo`, install the bundled `udev` rules:
+#### Arch Linux (AUR)
```bash
-sudo cp udev/99-insta360-link.rules /etc/udev/rules.d/99-insta360-link.rules
-sudo udevadm control --reload-rules
-sudo udevadm trigger
+yay -S insta360-link-controller-bin
```
-Unplug and reconnect your Insta360 Link camera if it is already connected.
-
-### Windows (`WinUSB` / Zadig Note)
-
-On Windows, the standard `usbvideo.sys` UVC driver claims the composite video interfaces. To allow direct USB control transfers via `nusb`:
-- Use [Zadig](https://zadig.akeo.ie/) (Options → **List All Devices**, uncheck **Ignore Hubs or Composite Parents**) to bind the camera's **UVC VideoControl interface (Interface 0)** to the **WinUSB** driver while leaving the VideoStreaming interface on `usbvideo.sys`, **or**
-- Run with `--mock` to test the CLI, server, and GUI using the built-in hardware simulator.
-
### macOS
On macOS, `nusb` uses `IOKit` to send UVC class-specific control requests directly to Endpoint 0 without detaching the macOS `UVCAssistant` / `AVFoundation` camera stream. No kernel extensions or extra permissions are required.
diff --git a/assets/insta360-link-controller.desktop b/assets/insta360-link-controller.desktop
new file mode 100644
index 0000000..dab6430
--- /dev/null
+++ b/assets/insta360-link-controller.desktop
@@ -0,0 +1,14 @@
+[Desktop Entry]
+Version=1.0
+Type=Application
+Name=Insta360 Link Controller
+GenericName=Webcam Controller
+Comment=Hardware PTZ, AI tracking, image settings, and live preview controller for Insta360 Link webcams
+Exec=insta360-link-gui
+TryExec=insta360-link-gui
+Icon=insta360-link-controller
+Terminal=false
+Categories=AudioVideo;Video;Settings;HardwareSettings;
+Keywords=insta360;link;webcam;camera;ptz;gimbal;uvc;video;tracking;
+StartupNotify=true
+StartupWMClass=insta360-link-controller
diff --git a/assets/insta360-link-controller.svg b/assets/insta360-link-controller.svg
new file mode 100644
index 0000000..ef3eb46
--- /dev/null
+++ b/assets/insta360-link-controller.svg
@@ -0,0 +1,18 @@
+
diff --git a/crates/insta360-link-gui/src/main.rs b/crates/insta360-link-gui/src/main.rs
index ad11754..009dad6 100644
--- a/crates/insta360-link-gui/src/main.rs
+++ b/crates/insta360-link-gui/src/main.rs
@@ -2393,12 +2393,62 @@ impl eframe::App for Insta360App {
}
}
+fn build_app_icon() -> egui::IconData {
+ let w = 64u32;
+ let h = 64u32;
+ let mut rgba = vec![0u8; (w * h * 4) as usize];
+ for y in 0..h {
+ for x in 0..w {
+ let idx = ((y * w + x) * 4) as usize;
+ let dx = x as f32 - 31.5_f32;
+ let dy = y as f32 - 26.0_f32;
+ let dist = (dx * dx + dy * dy).sqrt();
+ // Dark rounded card background
+ let in_card = x >= 4 && x < 60 && y >= 4 && y < 60;
+ if !in_card {
+ continue;
+ }
+ let (mut r, mut g, mut b) = (20u8, 24u8, 32u8);
+ // Green camera ring
+ if (10.5_f32..=14.0_f32).contains(&dist) {
+ r = 0;
+ g = 230;
+ b = 118;
+ } else if dist < 5.0_f32 {
+ r = 0;
+ g = 230;
+ b = 118;
+ } else if dist < 10.5_f32 {
+ r = 12;
+ g = 15;
+ b = 20;
+ } else if (18..=46).contains(&x) && (44..=52).contains(&y) {
+ // Gimbal base
+ r = 43;
+ g = 50;
+ b = 66;
+ }
+ rgba[idx] = r;
+ rgba[idx + 1] = g;
+ rgba[idx + 2] = b;
+ rgba[idx + 3] = 255;
+ }
+ }
+ egui::IconData {
+ rgba,
+ width: w,
+ height: h,
+ }
+}
+
fn main() -> eframe::Result<()> {
tracing_subscriber::fmt::init();
// Matching webcam.qml: width: 1440, height: 714, minimumWidth: 1120, minimumHeight: 536
let native_options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
+ .with_app_id("insta360-link-controller")
+ .with_icon(build_app_icon())
.with_inner_size([1440.0_f32, 714.0_f32])
.with_min_inner_size([1120.0_f32, 536.0_f32])
.with_title("Insta360 Link Controller"),
diff --git a/crates/insta360-link-protocol/src/transport.rs b/crates/insta360-link-protocol/src/transport.rs
index 05993ba..76c0a18 100644
--- a/crates/insta360-link-protocol/src/transport.rs
+++ b/crates/insta360-link-protocol/src/transport.rs
@@ -72,6 +72,12 @@ pub fn list_connected_cameras() -> Result, TransportError>
unit_ids = parse_uvc_vc_descriptors(raw);
}
}
+ #[cfg(target_os = "linux")]
+ if unit_ids == UvcUnitIds::default() {
+ if let Some((_, parsed_ids)) = open_linux_v4l2_node(vid, pid, unit_ids.extension_unit_id) {
+ unit_ids = parsed_ids;
+ }
+ }
out.push(DiscoveredCamera {
vendor_id: vid,
@@ -89,10 +95,10 @@ pub fn list_connected_cameras() -> Result, TransportError>
}
/// Pure-Rust cross-platform USB hardware transport backed by `nusb`
-/// (Linux `uvcvideo` `UVCIOC_CTRL_QUERY` + `usbfs`, Windows `WinUSB`, macOS `IOKit`).
+/// (Linux `uvcvideo` `UVCIOC_CTRL_QUERY` + V4L2 ioctls, Windows `WinUSB`, macOS `IOKit`).
pub struct NusbTransport {
info: DiscoveredCamera,
- device: nusb::Device,
+ device: Option,
interface: Option,
#[cfg(target_os = "linux")]
v4l_file: Option,
@@ -100,7 +106,7 @@ pub struct NusbTransport {
}
#[cfg(target_os = "linux")]
-fn open_linux_v4l2_node(vid: u16, pid: u16, xu1_id: u8) -> Option {
+fn open_linux_v4l2_node(vid: u16, pid: u16, xu1_id: u8) -> Option<(std::fs::File, UvcUnitIds)> {
let vid_hex = format!("{:04x}", vid);
let pid_hex = format!("{:04x}", pid);
let entries = std::fs::read_dir("/sys/class/video4linux").ok()?;
@@ -115,13 +121,13 @@ fn open_linux_v4l2_node(vid: u16, pid: u16, xu1_id: u8) -> Option
}
candidates.sort_by_key(|(num, _, _)| *num);
- let mut fallback: Option = None;
+ let mut fallback: Option<(std::fs::File, UvcUnitIds)> = None;
for (_, path, name) in candidates {
let dev_link = path.join("device");
let Ok(canon) = std::fs::canonicalize(&dev_link) else {
continue;
};
- // Parent directory of the USB interface (`B-P:1.0`) holds `idVendor` and `idProduct`.
+ // Parent directory of the USB interface (`B-P:1.0`) holds `idVendor`, `idProduct`, and `descriptors`.
if let Some(usb_dev_dir) = canon.parent() {
let v = std::fs::read_to_string(usb_dev_dir.join("idVendor"))
.unwrap_or_default()
@@ -132,6 +138,23 @@ fn open_linux_v4l2_node(vid: u16, pid: u16, xu1_id: u8) -> Option
.trim()
.to_lowercase();
if v == vid_hex && p == pid_hex {
+ let parsed_ids = std::fs::read(usb_dev_dir.join("descriptors"))
+ .ok()
+ .map(|raw| {
+ // Skip the 18-byte USB Device Descriptor if present at offset 0
+ let slice = if raw.len() > 18 && raw[0] == 18 && raw[1] == 1 {
+ &raw[18..]
+ } else {
+ &raw[..]
+ };
+ parse_uvc_vc_descriptors(slice)
+ })
+ .unwrap_or_default();
+ let probe_xu1 = if parsed_ids.extension_unit_id != 0 {
+ parsed_ids.extension_unit_id
+ } else {
+ xu1_id
+ };
let dev_path = format!("/dev/{}", name);
if let Ok(file) = std::fs::OpenOptions::new()
.read(true)
@@ -143,17 +166,17 @@ fn open_linux_v4l2_node(vid: u16, pid: u16, xu1_id: u8) -> Option
let mut len_buf = [0u8; 2];
if linux_uvcioc_ctrl_query(
&file,
- xu1_id,
+ probe_xu1,
0x03,
UvcRequest::GetLen as u8,
&mut len_buf,
)
.is_ok()
{
- return Some(file);
+ return Some((file, parsed_ids));
}
if fallback.is_none() {
- fallback = Some(file);
+ fallback = Some((file, parsed_ids));
}
}
}
@@ -352,25 +375,49 @@ impl NusbTransport {
})
.ok_or(TransportError::DeviceNotFound)?;
- let device = dev_info.open().map_err(|e| TransportError::Usb(e.to_string()))?;
- if let Ok(cfg) = device.active_configuration() {
- let raw: &[u8] = cfg.descriptors().as_bytes();
- info.unit_ids = parse_uvc_vc_descriptors(raw);
+ let device = dev_info.open().ok();
+ if let Some(ref dev) = device {
+ if let Ok(cfg) = dev.active_configuration() {
+ let raw: &[u8] = cfg.descriptors().as_bytes();
+ info.unit_ids = parse_uvc_vc_descriptors(raw);
+ }
}
// Claim the UVC VideoControl interface without detaching the OS kernel UVC video streaming
// driver (`uvcvideo` on Linux, `UVCAssistant` on macOS, `usbvideo.sys` on Windows) so that
// live webcam video capture and UVC/XU control transfers operate simultaneously.
let interface = device
- .claim_interface(info.unit_ids.control_interface)
- .ok();
+ .as_ref()
+ .and_then(|d| d.claim_interface(info.unit_ids.control_interface).ok());
#[cfg(target_os = "linux")]
- let v4l_file = open_linux_v4l2_node(
+ let v4l_file = match open_linux_v4l2_node(
info.vendor_id,
info.product_id,
info.unit_ids.extension_unit_id,
- );
+ ) {
+ Some((f, parsed_ids)) => {
+ if device.is_none() {
+ info.unit_ids = parsed_ids;
+ }
+ Some(f)
+ }
+ None => None,
+ };
+
+ #[cfg(target_os = "linux")]
+ if device.is_none() && v4l_file.is_none() {
+ return Err(TransportError::Usb(
+ "Could not open V4L2 device node or USB device".to_string(),
+ ));
+ }
+
+ #[cfg(not(target_os = "linux"))]
+ if device.is_none() {
+ return Err(TransportError::Usb(
+ "Could not open USB device".to_string(),
+ ));
+ }
Ok(Self {
info,
@@ -486,18 +533,15 @@ impl UvcControlTransport for NusbTransport {
.map_err(|e| TransportError::Usb(e.to_string()))
} else {
#[cfg(not(target_os = "windows"))]
- {
- self.device
+ if let Some(dev) = &self.device {
+ return dev
.control_out_blocking(control, payload, self.timeout)
.map(|_| ())
- .map_err(|e| TransportError::Usb(e.to_string()))
- }
- #[cfg(target_os = "windows")]
- {
- Err(TransportError::Usb(
- "UVC control interface could not be claimed on Windows".to_string(),
- ))
+ .map_err(|e| TransportError::Usb(e.to_string()));
}
+ Err(TransportError::Usb(
+ "UVC control transfer failed".to_string(),
+ ))
}
}
@@ -590,17 +634,16 @@ impl UvcControlTransport for NusbTransport {
.map_err(|e| TransportError::Usb(e.to_string()))?
} else {
#[cfg(not(target_os = "windows"))]
- {
- self.device
+ if let Some(dev) = &self.device {
+ let n = dev
.control_in_blocking(control, &mut buf, self.timeout)
- .map_err(|e| TransportError::Usb(e.to_string()))?
- }
- #[cfg(target_os = "windows")]
- {
- return Err(TransportError::Usb(
- "UVC control interface could not be claimed on Windows".to_string(),
- ));
+ .map_err(|e| TransportError::Usb(e.to_string()))?;
+ buf.truncate(n);
+ return Ok(buf);
}
+ return Err(TransportError::Usb(
+ "UVC control transfer failed".to_string(),
+ ));
};
buf.truncate(n);
Ok(buf)
diff --git a/mise.toml b/mise.toml
index 7450a32..ebf2f28 100644
--- a/mise.toml
+++ b/mise.toml
@@ -29,10 +29,6 @@ run = "cargo test --workspace"
description = "Run the automated live hardware GUI verification sequence"
run = "INSTA360_GUI_SELF_TEST=1 cargo run --release -p insta360-link-gui"
-[tasks.udev-install]
-description = "Install Linux udev rules for non-root USB access to Insta360 Link webcams"
-run = "sudo cp udev/99-insta360-link.rules /etc/udev/rules.d/99-insta360-link.rules && sudo udevadm control --reload-rules && sudo udevadm trigger"
-
[tasks.package-release]
description = "Build and package release binaries for a target (usage: mise run package-release [target] [version])"
run = '''
@@ -96,7 +92,7 @@ else
cp "target/${TARGET}/release/insta360-link-ctl" "$STAGE_DIR/"
cp "target/${TARGET}/release/insta360-link-server" "$STAGE_DIR/"
cp README.md "$STAGE_DIR/"
- cp -r udev "$STAGE_DIR/"
+ cp -r assets "$STAGE_DIR/"
tar -C dist -czf "dist/${STAGE_NAME}.tar.gz" "$STAGE_NAME"
echo "Packaged dist/${STAGE_NAME}.tar.gz"
fi
diff --git a/udev/99-insta360-link.rules b/udev/99-insta360-link.rules
deleted file mode 100644
index 1e3e11a..0000000
--- a/udev/99-insta360-link.rules
+++ /dev/null
@@ -1,37 +0,0 @@
-# Linux udev rules for Insta360 Link / Link 2 / Link 2C / Link 2 Pro (PUC3) webcams
-#
-# Grants non-root desktop users (via systemd-logind uaccess and plugdev/video groups)
-# direct read/write access to the USB device nodes so `insta360-link-ctl`,
-# `insta360-link-server`, and `insta360-link-gui` can issue UVC Extension Unit
-# control transfers without requiring `sudo`.
-#
-# Installation:
-# sudo cp udev/99-insta360-link.rules /etc/udev/rules.d/99-insta360-link.rules
-# sudo udevadm control --reload-rules
-# sudo udevadm trigger
-
-ACTION!="add|change", GOTO="insta360_link_end"
-SUBSYSTEM!="usb", GOTO="insta360_link_end"
-
-# Insta360 Link (1st Gen - 0x4c01)
-ATTRS{idVendor}=="2e1a", ATTRS{idProduct}=="4c01", MODE="0660", GROUP="plugdev", TAG+="uaccess"
-
-# Insta360 Link 2 Pro / PUC3 Gimbal A (0x4c02)
-ATTRS{idVendor}=="2e1a", ATTRS{idProduct}=="4c02", MODE="0660", GROUP="plugdev", TAG+="uaccess"
-
-# Insta360 Link 2C (Fixed-Lens 4K - 0x4c03)
-ATTRS{idVendor}=="2e1a", ATTRS{idProduct}=="4c03", MODE="0660", GROUP="plugdev", TAG+="uaccess"
-
-# Insta360 Link 2 (2-Axis Gimbal 4K - 0x4c04)
-ATTRS{idVendor}=="2e1a", ATTRS{idProduct}=="4c04", MODE="0660", GROUP="plugdev", TAG+="uaccess"
-
-# Insta360 Link 2C Pro / PUC3 Host (0x4c05)
-ATTRS{idVendor}=="2e1a", ATTRS{idProduct}=="4c05", MODE="0660", GROUP="plugdev", TAG+="uaccess"
-
-# Insta360 Link 2 Pro / PUC3 Gimbal B (0x4c06)
-ATTRS{idVendor}=="2e1a", ATTRS{idProduct}=="4c06", MODE="0660", GROUP="plugdev", TAG+="uaccess"
-
-# Insta360 Link Simple-Class / Firmware Upgrade Mode (0x00c2)
-ATTRS{idVendor}=="2e1a", ATTRS{idProduct}=="00c2", MODE="0660", GROUP="plugdev", TAG+="uaccess"
-
-LABEL="insta360_link_end"