Remove udev rules, add GNOME desktop entry & SVG icon, and support V4L2-only open on Linux

This commit is contained in:
Lucas Oskorep committed 2026-10-20 17:57:43 -04:00
1 parent 3422b14e1d
commit f0e21a7e74
7 files changed
+165 -94

No files matched your search

+5 -18
View File
@@ -41,9 +41,6 @@ This project includes a [`mise.toml`](mise.toml) configuration that manages the
mise trust mise trust
mise install 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 # Build & launch the desktop GUI
mise run 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 ```bash
sudo cp udev/99-insta360-link.rules /etc/udev/rules.d/99-insta360-link.rules yay -S insta360-link-controller-bin
sudo udevadm control --reload-rules
sudo udevadm trigger
``` ```
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 ### 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. 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.
+14
View File
@@ -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
+18
View File
@@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#181c24"/>
<stop offset="100%" stop-color="#0e1117"/>
</linearGradient>
<linearGradient id="ring" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#00e676"/>
<stop offset="100%" stop-color="#00b248"/>
</linearGradient>
</defs>
<rect x="8" y="8" width="112" height="112" rx="26" fill="url(#bg)" stroke="#2a3140" stroke-width="3"/>
<rect x="34" y="24" width="60" height="46" rx="14" fill="#232936" stroke="url(#ring)" stroke-width="4"/>
<circle cx="64" cy="47" r="14" fill="#0d1016" stroke="#00e676" stroke-width="3"/>
<circle cx="64" cy="47" r="6" fill="#00e676"/>
<path d="M52 70 L52 86 L76 86 L76 70" fill="#232936" stroke="#3a4254" stroke-width="3" stroke-linejoin="round"/>
<rect x="36" y="86" width="56" height="14" rx="7" fill="#2b3242" stroke="#00e676" stroke-width="2.5"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+50
View File
@@ -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<()> { fn main() -> eframe::Result<()> {
tracing_subscriber::fmt::init(); tracing_subscriber::fmt::init();
// Matching webcam.qml: width: 1440, height: 714, minimumWidth: 1120, minimumHeight: 536 // Matching webcam.qml: width: 1440, height: 714, minimumWidth: 1120, minimumHeight: 536
let native_options = eframe::NativeOptions { let native_options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default() 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_inner_size([1440.0_f32, 714.0_f32])
.with_min_inner_size([1120.0_f32, 536.0_f32]) .with_min_inner_size([1120.0_f32, 536.0_f32])
.with_title("Insta360 Link Controller"), .with_title("Insta360 Link Controller"),
+77 -34
View File
@@ -72,6 +72,12 @@ pub fn list_connected_cameras() -> Result<Vec<DiscoveredCamera>, TransportError>
unit_ids = parse_uvc_vc_descriptors(raw); 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 { out.push(DiscoveredCamera {
vendor_id: vid, vendor_id: vid,
@@ -89,10 +95,10 @@ pub fn list_connected_cameras() -> Result<Vec<DiscoveredCamera>, TransportError>
} }
/// Pure-Rust cross-platform USB hardware transport backed by `nusb` /// 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 { pub struct NusbTransport {
info: DiscoveredCamera, info: DiscoveredCamera,
device: nusb::Device, device: Option<nusb::Device>,
interface: Option<nusb::Interface>, interface: Option<nusb::Interface>,
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
v4l_file: Option<std::fs::File>, v4l_file: Option<std::fs::File>,
@@ -100,7 +106,7 @@ pub struct NusbTransport {
} }
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
fn open_linux_v4l2_node(vid: u16, pid: u16, xu1_id: u8) -> Option<std::fs::File> { fn open_linux_v4l2_node(vid: u16, pid: u16, xu1_id: u8) -> Option<(std::fs::File, UvcUnitIds)> {
let vid_hex = format!("{:04x}", vid); let vid_hex = format!("{:04x}", vid);
let pid_hex = format!("{:04x}", pid); let pid_hex = format!("{:04x}", pid);
let entries = std::fs::read_dir("/sys/class/video4linux").ok()?; 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<std::fs::File>
} }
candidates.sort_by_key(|(num, _, _)| *num); candidates.sort_by_key(|(num, _, _)| *num);
let mut fallback: Option<std::fs::File> = None; let mut fallback: Option<(std::fs::File, UvcUnitIds)> = None;
for (_, path, name) in candidates { for (_, path, name) in candidates {
let dev_link = path.join("device"); let dev_link = path.join("device");
let Ok(canon) = std::fs::canonicalize(&dev_link) else { let Ok(canon) = std::fs::canonicalize(&dev_link) else {
continue; 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() { if let Some(usb_dev_dir) = canon.parent() {
let v = std::fs::read_to_string(usb_dev_dir.join("idVendor")) let v = std::fs::read_to_string(usb_dev_dir.join("idVendor"))
.unwrap_or_default() .unwrap_or_default()
@@ -132,6 +138,23 @@ fn open_linux_v4l2_node(vid: u16, pid: u16, xu1_id: u8) -> Option<std::fs::File>
.trim() .trim()
.to_lowercase(); .to_lowercase();
if v == vid_hex && p == pid_hex { 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); let dev_path = format!("/dev/{}", name);
if let Ok(file) = std::fs::OpenOptions::new() if let Ok(file) = std::fs::OpenOptions::new()
.read(true) .read(true)
@@ -143,17 +166,17 @@ fn open_linux_v4l2_node(vid: u16, pid: u16, xu1_id: u8) -> Option<std::fs::File>
let mut len_buf = [0u8; 2]; let mut len_buf = [0u8; 2];
if linux_uvcioc_ctrl_query( if linux_uvcioc_ctrl_query(
&file, &file,
xu1_id, probe_xu1,
0x03, 0x03,
UvcRequest::GetLen as u8, UvcRequest::GetLen as u8,
&mut len_buf, &mut len_buf,
) )
.is_ok() .is_ok()
{ {
return Some(file); return Some((file, parsed_ids));
} }
if fallback.is_none() { if fallback.is_none() {
fallback = Some(file); fallback = Some((file, parsed_ids));
} }
} }
} }
@@ -352,25 +375,49 @@ impl NusbTransport {
}) })
.ok_or(TransportError::DeviceNotFound)?; .ok_or(TransportError::DeviceNotFound)?;
let device = dev_info.open().map_err(|e| TransportError::Usb(e.to_string()))?; let device = dev_info.open().ok();
if let Ok(cfg) = device.active_configuration() { if let Some(ref dev) = device {
let raw: &[u8] = cfg.descriptors().as_bytes(); if let Ok(cfg) = dev.active_configuration() {
info.unit_ids = parse_uvc_vc_descriptors(raw); 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 // 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 // driver (`uvcvideo` on Linux, `UVCAssistant` on macOS, `usbvideo.sys` on Windows) so that
// live webcam video capture and UVC/XU control transfers operate simultaneously. // live webcam video capture and UVC/XU control transfers operate simultaneously.
let interface = device let interface = device
.claim_interface(info.unit_ids.control_interface) .as_ref()
.ok(); .and_then(|d| d.claim_interface(info.unit_ids.control_interface).ok());
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
let v4l_file = open_linux_v4l2_node( let v4l_file = match open_linux_v4l2_node(
info.vendor_id, info.vendor_id,
info.product_id, info.product_id,
info.unit_ids.extension_unit_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 { Ok(Self {
info, info,
@@ -486,18 +533,15 @@ impl UvcControlTransport for NusbTransport {
.map_err(|e| TransportError::Usb(e.to_string())) .map_err(|e| TransportError::Usb(e.to_string()))
} else { } else {
#[cfg(not(target_os = "windows"))] #[cfg(not(target_os = "windows"))]
{ if let Some(dev) = &self.device {
self.device return dev
.control_out_blocking(control, payload, self.timeout) .control_out_blocking(control, payload, self.timeout)
.map(|_| ()) .map(|_| ())
.map_err(|e| TransportError::Usb(e.to_string())) .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(),
))
} }
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()))? .map_err(|e| TransportError::Usb(e.to_string()))?
} else { } else {
#[cfg(not(target_os = "windows"))] #[cfg(not(target_os = "windows"))]
{ if let Some(dev) = &self.device {
self.device let n = dev
.control_in_blocking(control, &mut buf, self.timeout) .control_in_blocking(control, &mut buf, self.timeout)
.map_err(|e| TransportError::Usb(e.to_string()))? .map_err(|e| TransportError::Usb(e.to_string()))?;
} buf.truncate(n);
#[cfg(target_os = "windows")] return Ok(buf);
{
return Err(TransportError::Usb(
"UVC control interface could not be claimed on Windows".to_string(),
));
} }
return Err(TransportError::Usb(
"UVC control transfer failed".to_string(),
));
}; };
buf.truncate(n); buf.truncate(n);
Ok(buf) Ok(buf)
+1 -5
View File
@@ -29,10 +29,6 @@ run = "cargo test --workspace"
description = "Run the automated live hardware GUI verification sequence" description = "Run the automated live hardware GUI verification sequence"
run = "INSTA360_GUI_SELF_TEST=1 cargo run --release -p insta360-link-gui" 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] [tasks.package-release]
description = "Build and package release binaries for a target (usage: mise run package-release [target] [version])" description = "Build and package release binaries for a target (usage: mise run package-release [target] [version])"
run = ''' run = '''
@@ -96,7 +92,7 @@ else
cp "target/${TARGET}/release/insta360-link-ctl" "$STAGE_DIR/" cp "target/${TARGET}/release/insta360-link-ctl" "$STAGE_DIR/"
cp "target/${TARGET}/release/insta360-link-server" "$STAGE_DIR/" cp "target/${TARGET}/release/insta360-link-server" "$STAGE_DIR/"
cp README.md "$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" tar -C dist -czf "dist/${STAGE_NAME}.tar.gz" "$STAGE_NAME"
echo "Packaged dist/${STAGE_NAME}.tar.gz" echo "Packaged dist/${STAGE_NAME}.tar.gz"
fi fi
-37
View File
@@ -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"