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

22
pnpm-lock.yaml generated
View File

@@ -9,7 +9,7 @@ importers:
.:
dependencies:
'@tauri-apps/api':
specifier: ^1
specifier: ^1.6.0
version: 1.6.0
'@vueuse/core':
specifier: ^10.11.0
@@ -18,7 +18,7 @@ importers:
specifier: ^2.1.7
version: 2.1.7(typescript@5.5.3)(vue@3.4.31(typescript@5.5.3))
vue:
specifier: ^3.3.4
specifier: ^3.4.31
version: 3.4.31(typescript@5.5.3)
vue-router:
specifier: ^4.4.0
@@ -28,7 +28,7 @@ importers:
version: 2.3.2(vue@3.4.31(typescript@5.5.3))
devDependencies:
'@tauri-apps/cli':
specifier: ^1
specifier: ^1.6.0
version: 1.6.0
'@vitejs/plugin-vue':
specifier: ^5.0.5
@@ -46,16 +46,16 @@ importers:
specifier: ^3.4.4
version: 3.4.4
typescript:
specifier: ^5.2.2
specifier: ^5.5.3
version: 5.5.3
unplugin-vue-components:
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))
vite:
specifier: ^5.3.1
specifier: ^5.3.3
version: 5.3.3(sass@1.77.8)
vue-tsc:
specifier: ^2.0.22
specifier: ^2.0.26
version: 2.0.26(typescript@5.5.3)
packages:
@@ -550,8 +550,8 @@ packages:
resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==}
engines: {node: '>= 6'}
caniuse-lite@1.0.30001642:
resolution: {integrity: sha512-3XQ0DoRgLijXJErLSl+bLnJ+Et4KqV1PY6JJBGAFlsNsz31zeAIncyeZfLCabHK/jtSh+671RM9YMldxjUPZtA==}
caniuse-lite@1.0.30001716:
resolution: {integrity: sha512-49/c1+x3Kwz7ZIWt+4DvK3aMJy9oYXXG6/97JKsnjdCk/6n9vVyWL8NAwVt95Lwt9eigI10Hl782kDfZUUlRXw==}
chokidar@3.6.0:
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
@@ -1477,7 +1477,7 @@ snapshots:
autoprefixer@10.4.19(postcss@8.4.39):
dependencies:
browserslist: 4.23.2
caniuse-lite: 1.0.30001642
caniuse-lite: 1.0.30001716
fraction.js: 4.3.7
normalize-range: 0.1.2
picocolors: 1.0.1
@@ -1498,14 +1498,14 @@ snapshots:
browserslist@4.23.2:
dependencies:
caniuse-lite: 1.0.30001642
caniuse-lite: 1.0.30001716
electron-to-chromium: 1.4.827
node-releases: 2.0.14
update-browserslist-db: 1.1.0(browserslist@4.23.2)
camelcase-css@2.0.1: {}
caniuse-lite@1.0.30001642: {}
caniuse-lite@1.0.30001716: {}
chokidar@3.6.0:
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"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
btleplug = { version = "0.11.5", features = ["serde"] }
btleplug = { version = "0.11.8", features = ["serde"] }
tokio = { version = "1.38.0", features = ["full"] }
futures = "0.3.30"
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::CentralEvent::{DeviceDisconnected, DeviceDiscovered, DeviceUpdated};
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 std::error::Error;
use std::sync::Arc;
@@ -14,6 +14,7 @@ use tokio::sync::Mutex;
#[derive(serde::Serialize, Clone)]
struct BleDevice {
peripheral_id: String,
name: String,
address: BDAddr,
rssi: i16,
@@ -67,14 +68,17 @@ impl BleConnection {
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();
let central = self.central.lock().await;
let peripheral = central
.as_ref()
.unwrap()
.peripheral(&PeripheralId::from(address))
.await?;
.peripherals()
.await?
.into_iter()
.find(|p| p.id().to_string() == peripheral_id)
.ok_or_else(|| "5010")?;
peripheral.connect().await?;
peripheral.discover_services().await?;
// 如果 peripheral.services() 不包含 0x180D 服务,则返回错误
@@ -83,13 +87,18 @@ impl BleConnection {
.iter()
.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;
let peripheral = self.peripheral.lock().await;
let device = BleDevice {
peripheral_id: peripheral
.as_ref()
.unwrap()
.id()
.to_string(),
name: peripheral
.as_ref()
.unwrap()
@@ -106,7 +115,7 @@ impl BleConnection {
.await?
.unwrap()
.rssi
.unwrap(),
.unwrap_or(0)
};
let service = peripheral
@@ -136,7 +145,7 @@ impl BleConnection {
while let Some(notification) = notification_stream.next().await {
if notification.uuid == uuid_from_u16(0x2A37) {
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();
}
}
@@ -164,27 +173,24 @@ impl BleConnection {
tokio::spawn(async move {
while let Some(event) = event_stream.next().await {
match event {
DeviceDiscovered(peripheral) | DeviceUpdated(peripheral) => {
DeviceDiscovered(peripheral_id) | DeviceUpdated(peripheral_id) => {
let p = central_clone
.as_ref()
.unwrap()
.peripheral(&peripheral)
.peripheral(&peripheral_id)
.await
.unwrap();
let device = BleDevice {
name: p
.properties()
.await
.unwrap()
.unwrap()
.local_name
.unwrap_or("Unknown".to_string()),
address: p.address(),
rssi: p.properties().await.unwrap().unwrap().rssi.unwrap(),
};
app_handle
.emit_all("device-discovered", Some(device))
.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 {
peripheral_id: peripheral_id.to_string(),
name,
address: props.address,
rssi,
};
let _ = app_handle.emit_all("device-discovered", Some(device));
}
}
DeviceDisconnected(peripheral) => {
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> {
let peripheral = connection.peripheral.lock().await;
let device = BleDevice {
peripheral_id: peripheral
.as_ref()
.unwrap()
.id()
.to_string(),
name: peripheral
.as_ref()
.unwrap()
@@ -274,18 +285,18 @@ async fn get_connected_device(connection: State<'_, BleConnection>) -> Result<Bl
.unwrap()
.unwrap()
.rssi
.unwrap(),
.unwrap_or(0),
};
Ok(device)
}
#[tauri::command]
async fn connect(
address: BDAddr,
peripheral_id: String,
connection: State<'_, BleConnection>,
app_handle: AppHandle,
) -> 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())
} else {
Ok(true)

View File

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

View File

@@ -1,8 +1,8 @@
<script lang="ts" setup>
import { PropType } from 'vue';
import { computed, PropType } from 'vue';
import { Device, useBrcatStore } from '../stores';
defineProps({
const props = defineProps({
device: {
type: Object as PropType<Device>,
required: true
@@ -10,10 +10,14 @@ defineProps({
});
const emit = defineEmits(['connect']);
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>
<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="flex flex-col gap-1">
<span class="flex items-center gap-1 text-sm leading-none">
@@ -21,12 +25,12 @@ const store = useBrcatStore();
</span>
<span class="flex items-center gap-1 text-2xs text-neutral-400">
<SignalIndicator :rssi="device.rssi" />
{{ device.address }}
<span class="font-mono">{{ displayAddress }}</span>
</span>
</div>
</div>
<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>
</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 { computed, onMounted, ref } from 'vue';
import { useSnackbar } from 'vue3-snackbar';
import { useErrno } from '../composables/useErrno';
const store = useBrcatStore();
const snackbar = useSnackbar();
@@ -10,14 +11,14 @@ const is_connecting = ref(false);
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;
store.stopScan();
invoke('connect', { address })
.catch(err => {
invoke('connect', { peripheralId: peripheral_id })
.catch(errno => {
snackbar.add({
type: 'error',
text: `连接设备失败: ${err}`
text: useErrno(errno),
});
store.startScan();
}).finally(() => {
@@ -51,29 +52,24 @@ onMounted(() => {
</div>
<button class="btn outline" @click="invoke('disconnect')">断开连接</button>
</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" />
<!-- <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
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" />
<span class="text-sm font-semibold text-neutral-500">心率</span>
<span class="text-lg text-primary-400 pl-1">心率</span>
</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" />
<span class="text-sm font-semibold text-neutral-500">心率</span>
<span class="text-lg text-primary-400 pl-1">心率</span>
</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 v-else-if="store.is_scanning && scanning_devices.length === 0"
@@ -89,7 +85,7 @@ onMounted(() => {
<div class="flex flex-col gap-2 relative">
<TransitionGroup name="scan-device">
<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>
</div>
</div>

View File

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