feat: macOS compatibility

This commit is contained in:
2025-05-03 04:25:15 +08:00
parent dfb3e0eb53
commit ed2d916936
11 changed files with 447 additions and 269 deletions

View File

@@ -10,23 +10,23 @@
"tauri": "tauri" "tauri": "tauri"
}, },
"dependencies": { "dependencies": {
"@tauri-apps/api": "^1", "@tauri-apps/api": "^1.6.0",
"@vueuse/core": "^10.11.0", "@vueuse/core": "^10.11.0",
"pinia": "^2.1.7", "pinia": "^2.1.7",
"vue": "^3.3.4", "vue": "^3.4.31",
"vue-router": "^4.4.0", "vue-router": "^4.4.0",
"vue3-snackbar": "^2.3.2" "vue3-snackbar": "^2.3.2"
}, },
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "^1", "@tauri-apps/cli": "^1.6.0",
"@vitejs/plugin-vue": "^5.0.5", "@vitejs/plugin-vue": "^5.0.5",
"autoprefixer": "^10.4.19", "autoprefixer": "^10.4.19",
"postcss": "^8.4.39", "postcss": "^8.4.39",
"sass": "^1.77.8", "sass": "^1.77.8",
"tailwindcss": "^3.4.4", "tailwindcss": "^3.4.4",
"typescript": "^5.2.2", "typescript": "^5.5.3",
"unplugin-vue-components": "^0.27.2", "unplugin-vue-components": "^0.27.2",
"vite": "^5.3.1", "vite": "^5.3.3",
"vue-tsc": "^2.0.22" "vue-tsc": "^2.0.26"
} }
} }

22
pnpm-lock.yaml generated
View File

@@ -9,7 +9,7 @@ importers:
.: .:
dependencies: dependencies:
'@tauri-apps/api': '@tauri-apps/api':
specifier: ^1 specifier: ^1.6.0
version: 1.6.0 version: 1.6.0
'@vueuse/core': '@vueuse/core':
specifier: ^10.11.0 specifier: ^10.11.0
@@ -18,7 +18,7 @@ importers:
specifier: ^2.1.7 specifier: ^2.1.7
version: 2.1.7(typescript@5.5.3)(vue@3.4.31(typescript@5.5.3)) version: 2.1.7(typescript@5.5.3)(vue@3.4.31(typescript@5.5.3))
vue: vue:
specifier: ^3.3.4 specifier: ^3.4.31
version: 3.4.31(typescript@5.5.3) version: 3.4.31(typescript@5.5.3)
vue-router: vue-router:
specifier: ^4.4.0 specifier: ^4.4.0
@@ -28,7 +28,7 @@ importers:
version: 2.3.2(vue@3.4.31(typescript@5.5.3)) version: 2.3.2(vue@3.4.31(typescript@5.5.3))
devDependencies: devDependencies:
'@tauri-apps/cli': '@tauri-apps/cli':
specifier: ^1 specifier: ^1.6.0
version: 1.6.0 version: 1.6.0
'@vitejs/plugin-vue': '@vitejs/plugin-vue':
specifier: ^5.0.5 specifier: ^5.0.5
@@ -46,16 +46,16 @@ importers:
specifier: ^3.4.4 specifier: ^3.4.4
version: 3.4.4 version: 3.4.4
typescript: typescript:
specifier: ^5.2.2 specifier: ^5.5.3
version: 5.5.3 version: 5.5.3
unplugin-vue-components: unplugin-vue-components:
specifier: ^0.27.2 specifier: ^0.27.2
version: 0.27.2(@babel/parser@7.24.8)(rollup@4.18.1)(vue@3.4.31(typescript@5.5.3)) version: 0.27.2(@babel/parser@7.24.8)(rollup@4.18.1)(vue@3.4.31(typescript@5.5.3))
vite: vite:
specifier: ^5.3.1 specifier: ^5.3.3
version: 5.3.3(sass@1.77.8) version: 5.3.3(sass@1.77.8)
vue-tsc: vue-tsc:
specifier: ^2.0.22 specifier: ^2.0.26
version: 2.0.26(typescript@5.5.3) version: 2.0.26(typescript@5.5.3)
packages: packages:
@@ -550,8 +550,8 @@ packages:
resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==}
engines: {node: '>= 6'} engines: {node: '>= 6'}
caniuse-lite@1.0.30001642: caniuse-lite@1.0.30001716:
resolution: {integrity: sha512-3XQ0DoRgLijXJErLSl+bLnJ+Et4KqV1PY6JJBGAFlsNsz31zeAIncyeZfLCabHK/jtSh+671RM9YMldxjUPZtA==} resolution: {integrity: sha512-49/c1+x3Kwz7ZIWt+4DvK3aMJy9oYXXG6/97JKsnjdCk/6n9vVyWL8NAwVt95Lwt9eigI10Hl782kDfZUUlRXw==}
chokidar@3.6.0: chokidar@3.6.0:
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
@@ -1477,7 +1477,7 @@ snapshots:
autoprefixer@10.4.19(postcss@8.4.39): autoprefixer@10.4.19(postcss@8.4.39):
dependencies: dependencies:
browserslist: 4.23.2 browserslist: 4.23.2
caniuse-lite: 1.0.30001642 caniuse-lite: 1.0.30001716
fraction.js: 4.3.7 fraction.js: 4.3.7
normalize-range: 0.1.2 normalize-range: 0.1.2
picocolors: 1.0.1 picocolors: 1.0.1
@@ -1498,14 +1498,14 @@ snapshots:
browserslist@4.23.2: browserslist@4.23.2:
dependencies: dependencies:
caniuse-lite: 1.0.30001642 caniuse-lite: 1.0.30001716
electron-to-chromium: 1.4.827 electron-to-chromium: 1.4.827
node-releases: 2.0.14 node-releases: 2.0.14
update-browserslist-db: 1.1.0(browserslist@4.23.2) update-browserslist-db: 1.1.0(browserslist@4.23.2)
camelcase-css@2.0.1: {} camelcase-css@2.0.1: {}
caniuse-lite@1.0.30001642: {} caniuse-lite@1.0.30001716: {}
chokidar@3.6.0: chokidar@3.6.0:
dependencies: dependencies:

540
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -14,7 +14,7 @@ tauri-build = { version = "1", features = [] }
tauri = { version = "1", features = [ "window-create", "path-all", "dialog-all", "window-show", "window-hide", "window-maximize", "window-unmaximize", "window-unminimize", "window-start-dragging", "window-minimize", "window-close", "shell-open"] } tauri = { version = "1", features = [ "window-create", "path-all", "dialog-all", "window-show", "window-hide", "window-maximize", "window-unmaximize", "window-unminimize", "window-start-dragging", "window-minimize", "window-close", "shell-open"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
btleplug = { version = "0.11.5", features = ["serde"] } btleplug = { version = "0.11.8", features = ["serde"] }
tokio = { version = "1.38.0", features = ["full"] } tokio = { version = "1.38.0", features = ["full"] }
futures = "0.3.30" futures = "0.3.30"
uuid = "1.10.0" uuid = "1.10.0"

8
src-tauri/Info.plist Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSBluetoothAlwaysUsageDescription</key>
<string>Request Bluetooth for recovery BLE devices.</string>
</dict>
</plist>

View File

@@ -4,7 +4,7 @@
use btleplug::api::bleuuid::uuid_from_u16; use btleplug::api::bleuuid::uuid_from_u16;
use btleplug::api::CentralEvent::{DeviceDisconnected, DeviceDiscovered, DeviceUpdated}; use btleplug::api::CentralEvent::{DeviceDisconnected, DeviceDiscovered, DeviceUpdated};
use btleplug::api::{BDAddr, Central, Manager as _, Peripheral as _, ScanFilter}; use btleplug::api::{BDAddr, Central, Manager as _, Peripheral as _, ScanFilter};
use btleplug::platform::{Adapter, Manager as BtleManager, Peripheral, PeripheralId}; use btleplug::platform::{Adapter, Manager as BtleManager, Peripheral};
use futures::StreamExt; use futures::StreamExt;
use std::error::Error; use std::error::Error;
use std::sync::Arc; use std::sync::Arc;
@@ -14,6 +14,7 @@ use tokio::sync::Mutex;
#[derive(serde::Serialize, Clone)] #[derive(serde::Serialize, Clone)]
struct BleDevice { struct BleDevice {
peripheral_id: String,
name: String, name: String,
address: BDAddr, address: BDAddr,
rssi: i16, rssi: i16,
@@ -67,14 +68,17 @@ impl BleConnection {
peripheral.is_some() peripheral.is_some()
} }
pub async fn connect(&self, address: BDAddr, app: &AppHandle) -> Result<(), Box<dyn Error>> { pub async fn connect(&self, peripheral_id: String, app: &AppHandle) -> Result<(), Box<dyn Error>> {
self.stop_scan().await.unwrap(); self.stop_scan().await.unwrap();
let central = self.central.lock().await; let central = self.central.lock().await;
let peripheral = central let peripheral = central
.as_ref() .as_ref()
.unwrap() .unwrap()
.peripheral(&PeripheralId::from(address)) .peripherals()
.await?; .await?
.into_iter()
.find(|p| p.id().to_string() == peripheral_id)
.ok_or_else(|| "5010")?;
peripheral.connect().await?; peripheral.connect().await?;
peripheral.discover_services().await?; peripheral.discover_services().await?;
// 如果 peripheral.services() 不包含 0x180D 服务,则返回错误 // 如果 peripheral.services() 不包含 0x180D 服务,则返回错误
@@ -83,13 +87,18 @@ impl BleConnection {
.iter() .iter()
.any(|s| s.uuid == uuid_from_u16(0x180D)) .any(|s| s.uuid == uuid_from_u16(0x180D))
{ {
return Err("Peripheral does not have the required service".into()); return Err("5011".into());
} }
self.set_peripheral(Some(peripheral)).await; self.set_peripheral(Some(peripheral)).await;
let peripheral = self.peripheral.lock().await; let peripheral = self.peripheral.lock().await;
let device = BleDevice { let device = BleDevice {
peripheral_id: peripheral
.as_ref()
.unwrap()
.id()
.to_string(),
name: peripheral name: peripheral
.as_ref() .as_ref()
.unwrap() .unwrap()
@@ -106,7 +115,7 @@ impl BleConnection {
.await? .await?
.unwrap() .unwrap()
.rssi .rssi
.unwrap(), .unwrap_or(0)
}; };
let service = peripheral let service = peripheral
@@ -136,7 +145,7 @@ impl BleConnection {
while let Some(notification) = notification_stream.next().await { while let Some(notification) = notification_stream.next().await {
if notification.uuid == uuid_from_u16(0x2A37) { if notification.uuid == uuid_from_u16(0x2A37) {
let value = notification.value; let value = notification.value;
let heart_rate = value[1] as u16; let heart_rate = value[0] as u16;
app_clone.emit_all("heart-rate", heart_rate).unwrap(); app_clone.emit_all("heart-rate", heart_rate).unwrap();
} }
} }
@@ -164,27 +173,24 @@ impl BleConnection {
tokio::spawn(async move { tokio::spawn(async move {
while let Some(event) = event_stream.next().await { while let Some(event) = event_stream.next().await {
match event { match event {
DeviceDiscovered(peripheral) | DeviceUpdated(peripheral) => { DeviceDiscovered(peripheral_id) | DeviceUpdated(peripheral_id) => {
let p = central_clone let p = central_clone
.as_ref() .as_ref()
.unwrap() .unwrap()
.peripheral(&peripheral) .peripheral(&peripheral_id)
.await .await
.unwrap(); .unwrap();
if let Ok(Some(props)) = p.properties().await {
let name = props.local_name.unwrap_or("Unknown".to_string());
let rssi = props.rssi.unwrap_or(0);
let device = BleDevice { let device = BleDevice {
name: p peripheral_id: peripheral_id.to_string(),
.properties() name,
.await address: props.address,
.unwrap() rssi,
.unwrap()
.local_name
.unwrap_or("Unknown".to_string()),
address: p.address(),
rssi: p.properties().await.unwrap().unwrap().rssi.unwrap(),
}; };
app_handle let _ = app_handle.emit_all("device-discovered", Some(device));
.emit_all("device-discovered", Some(device)) }
.unwrap();
} }
DeviceDisconnected(peripheral) => { DeviceDisconnected(peripheral) => {
let mut p = self_clone.peripheral.lock().await; let mut p = self_clone.peripheral.lock().await;
@@ -256,6 +262,11 @@ async fn is_connected(connection: State<'_, BleConnection>) -> Result<bool, Stri
async fn get_connected_device(connection: State<'_, BleConnection>) -> Result<BleDevice, String> { async fn get_connected_device(connection: State<'_, BleConnection>) -> Result<BleDevice, String> {
let peripheral = connection.peripheral.lock().await; let peripheral = connection.peripheral.lock().await;
let device = BleDevice { let device = BleDevice {
peripheral_id: peripheral
.as_ref()
.unwrap()
.id()
.to_string(),
name: peripheral name: peripheral
.as_ref() .as_ref()
.unwrap() .unwrap()
@@ -274,18 +285,18 @@ async fn get_connected_device(connection: State<'_, BleConnection>) -> Result<Bl
.unwrap() .unwrap()
.unwrap() .unwrap()
.rssi .rssi
.unwrap(), .unwrap_or(0),
}; };
Ok(device) Ok(device)
} }
#[tauri::command] #[tauri::command]
async fn connect( async fn connect(
address: BDAddr, peripheral_id: String,
connection: State<'_, BleConnection>, connection: State<'_, BleConnection>,
app_handle: AppHandle, app_handle: AppHandle,
) -> Result<bool, String> { ) -> Result<bool, String> {
if let Err(e) = connection.connect(address, &app_handle).await { if let Err(e) = connection.connect(peripheral_id, &app_handle).await {
Err(e.to_string()) Err(e.to_string())
} else { } else {
Ok(true) Ok(true)

View File

@@ -32,7 +32,7 @@ listen("device-disconnected", (_) => {
</Transition> </Transition>
</RouterView> </RouterView>
</DrawerContainer> </DrawerContainer>
<Vue3Snackbar bottom right shadow :duration="5000"></Vue3Snackbar> <Vue3Snackbar bottom right shadow dense :border="'left'" :duration="5000"></Vue3Snackbar>
</div> </div>
</template> </template>

View File

@@ -1,8 +1,8 @@
<script lang="ts" setup> <script lang="ts" setup>
import { PropType } from 'vue'; import { computed, PropType } from 'vue';
import { Device, useBrcatStore } from '../stores'; import { Device, useBrcatStore } from '../stores';
defineProps({ const props = defineProps({
device: { device: {
type: Object as PropType<Device>, type: Object as PropType<Device>,
required: true required: true
@@ -10,10 +10,14 @@ defineProps({
}); });
const emit = defineEmits(['connect']); const emit = defineEmits(['connect']);
const store = useBrcatStore(); const store = useBrcatStore();
const displayAddress = computed(() => {
return props.device.address === '00:00:00:00:00:00' ? (props.device.peripheral_id || 'N/A') : props.device.address;
})
</script> </script>
<template> <template>
<div class="item w-full px-3 py-3 flex justify-between items-center bg-white rounded" :key="device.address"> <div class="item w-full px-3 py-3 flex justify-between items-center bg-white rounded" :key="device.peripheral_id">
<div class="h-full"> <div class="h-full">
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<span class="flex items-center gap-1 text-sm leading-none"> <span class="flex items-center gap-1 text-sm leading-none">
@@ -21,12 +25,12 @@ const store = useBrcatStore();
</span> </span>
<span class="flex items-center gap-1 text-2xs text-neutral-400"> <span class="flex items-center gap-1 text-2xs text-neutral-400">
<SignalIndicator :rssi="device.rssi" /> <SignalIndicator :rssi="device.rssi" />
{{ device.address }} <span class="font-mono">{{ displayAddress }}</span>
</span> </span>
</div> </div>
</div> </div>
<div class="h-full flex items-center"> <div class="h-full flex items-center">
<button class="btn outline" :disabled="store.is_connected" @click="emit('connect', device.address)">连接</button> <button class="btn outline" :disabled="store.is_connected" @click="emit('connect', device.peripheral_id)">连接</button>
</div> </div>
</div> </div>
</template> </template>

View File

@@ -0,0 +1,14 @@
export const ERRNO: Record<number, string> = {
5010: '找不到指定 ID 的设备',
5011: '设备没有公开的心率服务'
}
export const useErrno = (errno: string | null | undefined) => {
if (errno) {
const err = ERRNO[Number(errno)]
if (err) {
return err
}
}
return '未知错误'
}

View File

@@ -3,6 +3,7 @@ import { invoke } from '@tauri-apps/api/tauri';
import { useBrcatStore } from '../stores'; import { useBrcatStore } from '../stores';
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { useSnackbar } from 'vue3-snackbar'; import { useSnackbar } from 'vue3-snackbar';
import { useErrno } from '../composables/useErrno';
const store = useBrcatStore(); const store = useBrcatStore();
const snackbar = useSnackbar(); const snackbar = useSnackbar();
@@ -10,14 +11,14 @@ const is_connecting = ref(false);
const scanning_devices = computed(() => store.scanning_devices.filter(d => d.name !== 'Unknown')); const scanning_devices = computed(() => store.scanning_devices.filter(d => d.name !== 'Unknown'));
async function connect(address: String) { async function connect(peripheral_id: String) {
is_connecting.value = true; is_connecting.value = true;
store.stopScan(); store.stopScan();
invoke('connect', { address }) invoke('connect', { peripheralId: peripheral_id })
.catch(err => { .catch(errno => {
snackbar.add({ snackbar.add({
type: 'error', type: 'error',
text: `连接设备失败: ${err}` text: useErrno(errno),
}); });
store.startScan(); store.startScan();
}).finally(() => { }).finally(() => {
@@ -51,29 +52,24 @@ onMounted(() => {
</div> </div>
<button class="btn outline" @click="invoke('disconnect')">断开连接</button> <button class="btn outline" @click="invoke('disconnect')">断开连接</button>
</div> </div>
<div class="flex-1 flex flex-col justify-center items-center px-4 py-2"> <div class="flex-1 flex flex-col justify-center items-center px-4 py-2 gap-2">
<img src="/favicon_256.ico" class="w-40 aspect-square opacity-30" /> <img src="/favicon_256.ico" class="w-40 aspect-square opacity-30" />
<!-- <div class="w-full grid grid-cols-3 gap-4"> <!-- TODO: Features entry (grid) -->
<div class="w-7/12 grid-cols-2 gap-4 hidden">
<div <div
class="px-4 py-3 rounded shadow-sm hover:shadow-md cursor-pointer transition bg-gradient-to-br from-neutral-50 to-primary-100"> class="p-3 pr-6 flex items-center justify-between gap-2 rounded-lg shadow-sm hover:shadow-md cursor-pointer transition bg-gradient-to-br from-neutral-50 to-primary-100">
<TablerHeartbeat class="text-primary text-5xl opacity-80 bg-primary-100 p-2 rounded-xl" /> <TablerHeartbeat class="text-primary text-5xl opacity-80 bg-primary-100 p-2 rounded-xl" />
<span class="text-sm font-semibold text-neutral-500">心率</span> <span class="text-lg text-primary-400 pl-1">心率</span>
</div> </div>
<div <div
class="px-4 py-3 rounded shadow-sm hover:shadow-md cursor-pointer transition bg-gradient-to-br from-neutral-50 to-primary-100"> class="p-3 pr-6 flex items-center justify-between gap-2 rounded-lg shadow-sm hover:shadow-md cursor-pointer transition bg-gradient-to-br from-neutral-50 to-primary-100">
<TablerHeartbeat class="text-primary text-5xl opacity-80 bg-primary-100 p-2 rounded-xl" /> <TablerHeartbeat class="text-primary text-5xl opacity-80 bg-primary-100 p-2 rounded-xl" />
<span class="text-sm font-semibold text-neutral-500">心率</span> <span class="text-lg text-primary-400 pl-1">心率</span>
</div> </div>
<div
class="px-4 py-3 rounded shadow-sm hover:shadow-md cursor-pointer transition bg-gradient-to-br from-neutral-50 to-primary-100">
<TablerHeartbeat class="text-primary text-5xl opacity-80 bg-primary-100 p-2 rounded-xl" />
<span class="text-sm font-semibold text-neutral-500">心率</span>
</div> </div>
</div> -->
</div> </div>
</div> </div>
<div v-else-if="store.is_scanning && scanning_devices.length === 0" <div v-else-if="store.is_scanning && scanning_devices.length === 0"
@@ -89,7 +85,7 @@ onMounted(() => {
<div class="flex flex-col gap-2 relative"> <div class="flex flex-col gap-2 relative">
<TransitionGroup name="scan-device"> <TransitionGroup name="scan-device">
<ScanningDevice v-for="(device, _) in scanning_devices.filter(d => d.name !== 'Unknown')" <ScanningDevice v-for="(device, _) in scanning_devices.filter(d => d.name !== 'Unknown')"
:key="device.address" :device="device" @connect="connect" /> :key="device.peripheral_id" :device="device" @connect="connect" />
</TransitionGroup> </TransitionGroup>
</div> </div>
</div> </div>

View File

@@ -5,6 +5,7 @@ import { listen } from "@tauri-apps/api/event";
import { useThrottleFn, useDebounceFn } from "@vueuse/core"; import { useThrottleFn, useDebounceFn } from "@vueuse/core";
export interface Device { export interface Device {
peripheral_id: string;
name: string; name: string;
address: string; address: string;
rssi: number; rssi: number;
@@ -58,14 +59,16 @@ export const useBrcatStore = defineStore("brcat", () => {
}, 2000); }, 2000);
function pushDevice(device: Device) { function pushDevice(device: Device) {
if (scanning_devices.value.some((d) => d.address === device.address)) { if (scanning_devices.value.some((d) => d.peripheral_id === device.peripheral_id)) {
scanning_devices.value = scanning_devices.value.map((d) => scanning_devices.value = scanning_devices.value.map((d) =>
d.address === device.address ? device : d d.peripheral_id === device.peripheral_id ? device : d
); );
} else { } else {
scanning_devices.value.push(device); scanning_devices.value.push(device);
} }
console.log(device);
throttledSort(); throttledSort();
} }