Merge pull request #10 from appergb/codex/release-1.1.2-polish
Release 1.1.3 platform setup fixes
This commit is contained in:
20 files changed
+429
-68
No files matched your search
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "openless-app",
|
||||
"version": "1.1.2",
|
||||
"version": "1.1.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "openless-app",
|
||||
"version": "1.1.2",
|
||||
"version": "1.1.3",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.1.1",
|
||||
"@tauri-apps/plugin-shell": "^2.0.1",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "openless-app",
|
||||
"private": true,
|
||||
"version": "1.1.2",
|
||||
"version": "1.1.3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -14,6 +14,7 @@ cd "$(dirname "$0")/.."
|
||||
|
||||
APP="src-tauri/target/release/bundle/macos/OpenLess.app"
|
||||
INFO="$APP/Contents/Info.plist"
|
||||
DMG_DIR="src-tauri/target/release/bundle/dmg"
|
||||
INSTALL="${INSTALL:-1}"
|
||||
|
||||
if [ -z "${APPLE_CERTIFICATE:-}" ] && [ -z "${APPLE_SIGNING_IDENTITY:-}" ]; then
|
||||
@@ -28,13 +29,26 @@ npm run tauri build
|
||||
|
||||
echo "▶ 校验 Info.plist / 签名"
|
||||
/usr/libexec/PlistBuddy -c "Print :NSMicrophoneUsageDescription" "$INFO" >/dev/null
|
||||
codesign -d --entitlements :- "$APP" 2>/dev/null | grep -q "com.apple.security.device.audio-input"
|
||||
codesign --verify --deep --strict --verbose=2 "$APP" 2>&1 | tail -2
|
||||
|
||||
echo "▶ 清理发布产物扩展属性"
|
||||
# 这只能保证 CI/本机构建产物本身干净;浏览器下载仍可能重新加 quarantine。
|
||||
# 用户免手工 xattr 的根本方案是 Developer ID 签名 + Apple notarization。
|
||||
xattr -cr "$APP" 2>/dev/null || true
|
||||
find src-tauri/target/release/bundle/dmg -maxdepth 1 -name '*.dmg' -exec xattr -c {} \; 2>/dev/null || true
|
||||
find "$DMG_DIR" -maxdepth 1 -name '*.dmg' -exec xattr -c {} \; 2>/dev/null || true
|
||||
|
||||
echo "▶ 校验 quarantine 属性"
|
||||
if xattr -pr com.apple.quarantine "$APP" >/dev/null 2>&1; then
|
||||
echo "✗ $APP 仍包含 com.apple.quarantine"
|
||||
exit 1
|
||||
fi
|
||||
while IFS= read -r dmg; do
|
||||
if xattr -p com.apple.quarantine "$dmg" >/dev/null 2>&1; then
|
||||
echo "✗ $dmg 仍包含 com.apple.quarantine"
|
||||
exit 1
|
||||
fi
|
||||
done < <(find "$DMG_DIR" -maxdepth 1 -name '*.dmg' -print)
|
||||
|
||||
if [ "$INSTALL" = "1" ]; then
|
||||
echo "▶ 装到 /Applications"
|
||||
|
||||
Generated
+1
-1
@@ -2937,7 +2937,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openless"
|
||||
version = "1.1.2"
|
||||
version = "1.1.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arboard",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "openless"
|
||||
version = "1.1.2"
|
||||
version = "1.1.3"
|
||||
description = "OpenLess — local voice input that types where your cursor is"
|
||||
authors = ["OpenLess"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -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>com.apple.security.device.audio-input</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -10,6 +10,8 @@
|
||||
"core:window:allow-show",
|
||||
"core:window:allow-hide",
|
||||
"core:window:allow-set-focus",
|
||||
"core:window:allow-minimize",
|
||||
"core:window:allow-toggle-maximize",
|
||||
"core:window:allow-close",
|
||||
"core:webview:default",
|
||||
"core:event:default",
|
||||
|
||||
@@ -21,10 +21,7 @@ pub fn get_settings(coord: CoordinatorState<'_>) -> UserPreferences {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_settings(
|
||||
coord: CoordinatorState<'_>,
|
||||
prefs: UserPreferences,
|
||||
) -> Result<(), String> {
|
||||
pub fn set_settings(coord: CoordinatorState<'_>, prefs: UserPreferences) -> Result<(), String> {
|
||||
coord.prefs().set(prefs).map_err(|e| e.to_string())?;
|
||||
coord.update_hotkey_binding();
|
||||
Ok(())
|
||||
@@ -199,8 +196,7 @@ pub fn check_microphone_permission() -> PermissionStatus {
|
||||
|
||||
#[tauri::command]
|
||||
pub fn request_microphone_permission(app: AppHandle) -> PermissionStatus {
|
||||
crate::show_main_window(&app);
|
||||
permissions::request_microphone()
|
||||
crate::request_microphone_from_foreground(&app)
|
||||
}
|
||||
|
||||
/// 跳到 macOS 系统设置的指定隐私面板。pane: "accessibility" | "microphone".
|
||||
@@ -209,8 +205,12 @@ pub fn open_system_settings(pane: String) -> Result<(), String> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let url = match pane.as_str() {
|
||||
"accessibility" => "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility",
|
||||
"microphone" => "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone",
|
||||
"accessibility" => {
|
||||
"x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility"
|
||||
}
|
||||
"microphone" => {
|
||||
"x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone"
|
||||
}
|
||||
_ => "x-apple.systempreferences:com.apple.preference.security?Privacy",
|
||||
};
|
||||
std::process::Command::new("open")
|
||||
@@ -230,9 +230,12 @@ pub fn open_system_settings(pane: String) -> Result<(), String> {
|
||||
/// 与 Swift `MicrophonePermission.request()` 同语义:只信系统权限回调,
|
||||
/// 不用 cpal stream 成功与否伪造授权状态。
|
||||
#[tauri::command]
|
||||
pub fn trigger_microphone_prompt() -> Result<(), String> {
|
||||
let status = permissions::request_microphone();
|
||||
if matches!(status, PermissionStatus::Granted | PermissionStatus::NotApplicable) {
|
||||
pub fn trigger_microphone_prompt(app: AppHandle) -> Result<(), String> {
|
||||
let status = crate::request_microphone_from_foreground(&app);
|
||||
if matches!(
|
||||
status,
|
||||
PermissionStatus::Granted | PermissionStatus::NotApplicable
|
||||
) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("microphone permission is {status:?}"))
|
||||
|
||||
@@ -14,9 +14,7 @@ use parking_lot::Mutex;
|
||||
use tauri::{async_runtime, AppHandle, Emitter, Manager};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::asr::{
|
||||
DictionaryHotword, RawTranscript, VolcengineCredentials, VolcengineStreamingASR,
|
||||
};
|
||||
use crate::asr::{DictionaryHotword, RawTranscript, VolcengineCredentials, VolcengineStreamingASR};
|
||||
use crate::hotkey::{HotkeyEvent, HotkeyMonitor};
|
||||
use crate::insertion::TextInserter;
|
||||
use crate::persistence::{
|
||||
@@ -105,9 +103,15 @@ impl Coordinator {
|
||||
.ok();
|
||||
}
|
||||
|
||||
pub fn history(&self) -> &HistoryStore { &self.inner.history }
|
||||
pub fn prefs(&self) -> &PreferencesStore { &self.inner.prefs }
|
||||
pub fn vocab(&self) -> &DictionaryStore { &self.inner.vocab }
|
||||
pub fn history(&self) -> &HistoryStore {
|
||||
&self.inner.history
|
||||
}
|
||||
pub fn prefs(&self) -> &PreferencesStore {
|
||||
&self.inner.prefs
|
||||
}
|
||||
pub fn vocab(&self) -> &DictionaryStore {
|
||||
&self.inner.vocab
|
||||
}
|
||||
|
||||
pub fn update_hotkey_binding(&self) {
|
||||
if let Some(monitor) = self.inner.hotkey.lock().as_ref() {
|
||||
@@ -129,7 +133,9 @@ impl Coordinator {
|
||||
|
||||
pub async fn repolish(&self, raw_text: String, mode: PolishMode) -> Result<String, String> {
|
||||
let hotwords = enabled_phrases(&self.inner);
|
||||
polish_text(&raw_text, mode, &hotwords).await.map_err(|e| e.to_string())
|
||||
polish_text(&raw_text, mode, &hotwords)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,7 +152,10 @@ fn hotkey_supervisor_loop(inner: Arc<Inner>) {
|
||||
match HotkeyMonitor::start(binding, tx) {
|
||||
Ok(monitor) => {
|
||||
*inner.hotkey.lock() = Some(monitor);
|
||||
log::info!("[coord] hotkey listener installed (after {} attempt(s))", attempts + 1);
|
||||
log::info!(
|
||||
"[coord] hotkey listener installed (after {} attempt(s))",
|
||||
attempts + 1
|
||||
);
|
||||
let inner_clone = Arc::clone(&inner);
|
||||
std::thread::Builder::new()
|
||||
.name("openless-hotkey-bridge".into())
|
||||
@@ -265,7 +274,12 @@ async fn begin_session(inner: &Arc<Inner>) -> Result<(), String> {
|
||||
let level_handler: Arc<dyn Fn(f32) + Send + Sync> = Arc::new(move |level| {
|
||||
let phase = inner_for_level.state.lock().phase;
|
||||
if phase == SessionPhase::Listening || phase == SessionPhase::Starting {
|
||||
let elapsed = inner_for_level.state.lock().started_at.elapsed().as_millis() as u64;
|
||||
let elapsed = inner_for_level
|
||||
.state
|
||||
.lock()
|
||||
.started_at
|
||||
.elapsed()
|
||||
.as_millis() as u64;
|
||||
emit_capsule(
|
||||
&inner_for_level,
|
||||
CapsuleState::Recording,
|
||||
@@ -420,15 +434,18 @@ fn ensure_microphone_permission(inner: &Arc<Inner>) -> Result<(), String> {
|
||||
use crate::permissions::{self, PermissionStatus};
|
||||
|
||||
let status = permissions::check_microphone();
|
||||
if matches!(status, PermissionStatus::Granted | PermissionStatus::NotApplicable) {
|
||||
if matches!(
|
||||
status,
|
||||
PermissionStatus::Granted | PermissionStatus::NotApplicable
|
||||
) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(app) = inner.app.lock().clone() {
|
||||
crate::show_main_window(&app);
|
||||
}
|
||||
|
||||
let requested = permissions::request_microphone();
|
||||
let requested = if let Some(app) = inner.app.lock().clone() {
|
||||
crate::request_microphone_from_foreground(&app)
|
||||
} else {
|
||||
permissions::request_microphone()
|
||||
};
|
||||
if matches!(
|
||||
requested,
|
||||
PermissionStatus::Granted | PermissionStatus::NotApplicable
|
||||
@@ -439,7 +456,11 @@ fn ensure_microphone_permission(inner: &Arc<Inner>) -> Result<(), String> {
|
||||
}
|
||||
}
|
||||
|
||||
async fn polish_or_passthrough(raw: &RawTranscript, mode: PolishMode, hotwords: &[String]) -> String {
|
||||
async fn polish_or_passthrough(
|
||||
raw: &RawTranscript,
|
||||
mode: PolishMode,
|
||||
hotwords: &[String],
|
||||
) -> String {
|
||||
if mode == PolishMode::Raw {
|
||||
return raw.text.clone();
|
||||
}
|
||||
|
||||
@@ -21,7 +21,11 @@ mod polish;
|
||||
mod recorder;
|
||||
mod types;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
use std::sync::mpsc;
|
||||
use std::sync::Arc;
|
||||
#[cfg(target_os = "macos")]
|
||||
use std::time::Duration;
|
||||
use tauri::menu::{MenuBuilder, MenuItemBuilder};
|
||||
use tauri::tray::{MouseButton, TrayIconBuilder, TrayIconEvent};
|
||||
use tauri::{AppHandle, LogicalPosition, Manager, RunEvent, Runtime};
|
||||
@@ -51,7 +55,9 @@ pub fn run() {
|
||||
if let Some(main) = app.get_webview_window("main") {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
use window_vibrancy::{apply_vibrancy, NSVisualEffectMaterial, NSVisualEffectState};
|
||||
use window_vibrancy::{
|
||||
apply_vibrancy, NSVisualEffectMaterial, NSVisualEffectState,
|
||||
};
|
||||
if let Err(e) = apply_vibrancy(
|
||||
&main,
|
||||
NSVisualEffectMaterial::HudWindow,
|
||||
@@ -99,7 +105,11 @@ pub fn run() {
|
||||
_ => {}
|
||||
})
|
||||
.on_tray_icon_event(|tray, event| {
|
||||
if let TrayIconEvent::Click { button: MouseButton::Left, .. } = event {
|
||||
if let TrayIconEvent::Click {
|
||||
button: MouseButton::Left,
|
||||
..
|
||||
} = event
|
||||
{
|
||||
show_main_window(tray.app_handle());
|
||||
}
|
||||
})
|
||||
@@ -164,9 +174,7 @@ fn init_file_logger() {
|
||||
let log_dir = log_dir_path();
|
||||
let _ = std::fs::create_dir_all(&log_dir);
|
||||
let log_file = log_dir.join("openless.log");
|
||||
let config = ConfigBuilder::new()
|
||||
.set_time_format_rfc3339()
|
||||
.build();
|
||||
let config = ConfigBuilder::new().set_time_format_rfc3339().build();
|
||||
let mut loggers: Vec<Box<dyn simplelog::SharedLogger>> = vec![TermLogger::new(
|
||||
LevelFilter::Info,
|
||||
config.clone(),
|
||||
@@ -224,6 +232,14 @@ pub(crate) fn show_main_window<R: Runtime>(app: &AppHandle<R>) {
|
||||
activate_app(app);
|
||||
}
|
||||
|
||||
pub(crate) fn request_microphone_from_foreground<R: Runtime>(
|
||||
app: &AppHandle<R>,
|
||||
) -> permissions::PermissionStatus {
|
||||
show_main_window(app);
|
||||
wait_for_app_activation(app);
|
||||
permissions::request_microphone()
|
||||
}
|
||||
|
||||
fn hide_main_window<R: Runtime>(app: &AppHandle<R>) {
|
||||
if let Some(w) = app.get_webview_window("main") {
|
||||
let _ = w.hide();
|
||||
@@ -269,9 +285,34 @@ fn activate_app<R: Runtime>(app: &AppHandle<R>) {
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn activate_app<R: Runtime>(_app: &AppHandle<R>) {
|
||||
fn activate_app<R: Runtime>(_app: &AppHandle<R>) {}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn wait_for_app_activation<R: Runtime>(app: &AppHandle<R>) {
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let _ = app.run_on_main_thread(move || {
|
||||
use objc2::msg_send;
|
||||
use objc2::runtime::{AnyClass, AnyObject, Bool};
|
||||
|
||||
unsafe {
|
||||
let Some(cls) = AnyClass::get("NSApplication") else {
|
||||
let _ = tx.send(());
|
||||
return;
|
||||
};
|
||||
let ns_app: *mut AnyObject = msg_send![cls, sharedApplication];
|
||||
if !ns_app.is_null() {
|
||||
let _: () = msg_send![ns_app, activateIgnoringOtherApps: Bool::YES];
|
||||
}
|
||||
}
|
||||
let _ = tx.send(());
|
||||
});
|
||||
let _ = rx.recv_timeout(Duration::from_millis(800));
|
||||
std::thread::sleep(Duration::from_millis(150));
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn wait_for_app_activation<R: Runtime>(_app: &AppHandle<R>) {}
|
||||
|
||||
/// 把 capsule 窗口移到屏幕底部居中,与 Swift `CapsuleWindowController.repositionToBottomCenter` 同效。
|
||||
/// 留 80pt 给 macOS Dock;Windows 任务栏一般在底部 48pt 以内,整体也合适。
|
||||
fn position_capsule_bottom_center<R: tauri::Runtime>(
|
||||
|
||||
@@ -61,13 +61,19 @@ impl Recorder {
|
||||
level_handler: Arc<dyn Fn(f32) + Send + Sync>,
|
||||
) -> Result<Self, RecorderError> {
|
||||
let status = permissions::check_microphone();
|
||||
if !matches!(status, PermissionStatus::Granted | PermissionStatus::NotApplicable) {
|
||||
if !matches!(
|
||||
status,
|
||||
PermissionStatus::Granted | PermissionStatus::NotApplicable
|
||||
) {
|
||||
let requested = permissions::request_microphone();
|
||||
if !matches!(
|
||||
requested,
|
||||
PermissionStatus::Granted | PermissionStatus::NotApplicable
|
||||
) {
|
||||
log::warn!("[recorder] microphone permission not granted: {:?}", requested);
|
||||
log::warn!(
|
||||
"[recorder] microphone permission not granted: {:?}",
|
||||
requested
|
||||
);
|
||||
return Err(RecorderError::PermissionDenied);
|
||||
}
|
||||
}
|
||||
@@ -359,12 +365,7 @@ fn downmix_to_mono(interleaved: &[f32], channels: usize) -> Vec<f32> {
|
||||
/// 算法说明:把上一回调的尾样本作为本回调起点,避免缝隙;用浮点
|
||||
/// `phase` 记录"已经走到上一帧的多少位置",每输出一个目标样本前进
|
||||
/// `step = src_sr / dst_sr`。
|
||||
fn resample_to_target(
|
||||
samples: &[f32],
|
||||
src_sr: u32,
|
||||
dst_sr: u32,
|
||||
state: &StreamState,
|
||||
) -> Vec<f32> {
|
||||
fn resample_to_target(samples: &[f32], src_sr: u32, dst_sr: u32, state: &StreamState) -> Vec<f32> {
|
||||
if samples.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "OpenLess",
|
||||
"version": "1.1.2",
|
||||
"version": "1.1.3",
|
||||
"identifier": "com.openless.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
@@ -62,7 +62,8 @@
|
||||
],
|
||||
"macOS": {
|
||||
"minimumSystemVersion": "12.0",
|
||||
"infoPlist": "Info.plist"
|
||||
"infoPlist": "Info.plist",
|
||||
"entitlements": "Entitlements.plist"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
//
|
||||
// Ported verbatim from design_handoff_openless/variants.jsx::FloatingShell.
|
||||
|
||||
import { type ComponentType } from 'react';
|
||||
import { useEffect, useState, type ComponentType } from 'react';
|
||||
import { Icon } from './Icon';
|
||||
import { WindowChrome, detectOS, type OS } from './WindowChrome';
|
||||
import { SettingsModal } from './SettingsModal';
|
||||
@@ -12,7 +12,14 @@ import { Overview } from '../pages/Overview';
|
||||
import { History } from '../pages/History';
|
||||
import { Vocab } from '../pages/Vocab';
|
||||
import { Style } from '../pages/Style';
|
||||
import { APP_VERSION_LABEL } from '../lib/appVersion';
|
||||
import { getCredentials } from '../lib/ipc';
|
||||
import { OL_DATA } from '../lib/mockData';
|
||||
import {
|
||||
PROVIDER_SETUP_PROMPT_SEEN_KEY,
|
||||
shouldShowProviderSetupPrompt,
|
||||
} from '../lib/providerSetup';
|
||||
import type { SettingsSectionId } from '../pages/Settings';
|
||||
import { useAppState, type AppTab } from '../state/useAppState';
|
||||
|
||||
interface NavItem {
|
||||
@@ -46,8 +53,39 @@ export function FloatingShell({ os: osProp, initialTab = 'overview', initialSett
|
||||
|
||||
function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initialTab: AppTab; initialSettings: boolean }) {
|
||||
const { currentTab, setCurrentTab, settingsOpen, setSettingsOpen } = useAppState(initialTab, initialSettings);
|
||||
const [settingsInitialSection, setSettingsInitialSection] = useState<SettingsSectionId | undefined>();
|
||||
const [providerPromptOpen, setProviderPromptOpen] = useState(false);
|
||||
const Page = (NAV.find((n) => n.id === currentTab) ?? NAV[0]).cmp;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const credentials = await getCredentials();
|
||||
const promptSeenValue = window.localStorage.getItem(PROVIDER_SETUP_PROMPT_SEEN_KEY);
|
||||
if (!cancelled && shouldShowProviderSetupPrompt(credentials, promptSeenValue)) {
|
||||
setProviderPromptOpen(true);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const rememberProviderPrompt = () => {
|
||||
window.localStorage.setItem(PROVIDER_SETUP_PROMPT_SEEN_KEY, '1');
|
||||
setProviderPromptOpen(false);
|
||||
};
|
||||
|
||||
const openSettings = (section?: SettingsSectionId) => {
|
||||
setSettingsInitialSection(section);
|
||||
setSettingsOpen(true);
|
||||
};
|
||||
|
||||
const openProviderSettings = () => {
|
||||
rememberProviderPrompt();
|
||||
openSettings('提供商');
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ flex: 1, position: 'relative', display: 'flex', flexDirection: 'column', minHeight: 0, paddingTop: os === 'mac' ? 36 : 0 }}>
|
||||
|
||||
@@ -84,7 +122,7 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
|
||||
marginLeft: 'auto', padding: '1px 6px', fontSize: 9.5, fontWeight: 600,
|
||||
borderRadius: 4, background: 'rgba(0,0,0,0.06)', color: 'var(--ol-ink-3)',
|
||||
letterSpacing: '0.04em',
|
||||
}}>v1.0</span>
|
||||
}}>{APP_VERSION_LABEL}</span>
|
||||
</div>
|
||||
|
||||
{/* nav */}
|
||||
@@ -195,19 +233,119 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
|
||||
|
||||
<FooterIcon name="user" tip="账户" />
|
||||
<FooterIcon name="mail" tip="反馈" />
|
||||
<FooterIcon name="settings" tip="设置" active={settingsOpen} onClick={() => setSettingsOpen(true)} />
|
||||
<FooterIcon name="settings" tip="设置" active={settingsOpen} onClick={() => openSettings()} />
|
||||
<FooterIcon name="help" tip="帮助" />
|
||||
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
<span style={{ fontFamily: 'var(--ol-font-sans)' }}>版本 v1.0.0</span>
|
||||
<span style={{ fontFamily: 'var(--ol-font-sans)' }}>版本 {APP_VERSION_LABEL}</span>
|
||||
<a style={{ color: 'var(--ol-blue)', marginLeft: 8, textDecoration: 'none', fontWeight: 500 }}>检查更新</a>
|
||||
</div>
|
||||
|
||||
{/* Settings modal — rendered inside this window */}
|
||||
{settingsOpen &&
|
||||
<SettingsModal os={os} onClose={() => setSettingsOpen(false)} />
|
||||
<SettingsModal
|
||||
key={settingsInitialSection ?? 'default'}
|
||||
os={os}
|
||||
initialSettingsSection={settingsInitialSection}
|
||||
onClose={() => setSettingsOpen(false)}
|
||||
/>
|
||||
}
|
||||
|
||||
{providerPromptOpen && (
|
||||
<ProviderSetupPrompt
|
||||
onLater={rememberProviderPrompt}
|
||||
onOpenSettings={openProviderSettings}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderSetupPrompt({ onLater, onOpenSettings }: { onLater: () => void; onOpenSettings: () => void }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
zIndex: 70,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 28,
|
||||
background: 'rgba(15,17,22,0.28)',
|
||||
backdropFilter: 'blur(2px)',
|
||||
WebkitBackdropFilter: 'blur(2px)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 360,
|
||||
borderRadius: 12,
|
||||
background: 'var(--ol-surface)',
|
||||
border: '0.5px solid rgba(0,0,0,.08)',
|
||||
boxShadow: '0 24px 70px -24px rgba(15,17,22,.38), 0 0 0 0.5px rgba(0,0,0,.06)',
|
||||
padding: 20,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: 8,
|
||||
background: 'rgba(37,99,235,0.10)',
|
||||
color: 'var(--ol-blue)',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Icon name="settings" size={17} />
|
||||
</div>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--ol-ink)' }}>设置语音提供商</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 12.5, color: 'var(--ol-ink-3)', lineHeight: 1.55 }}>
|
||||
还没有配置 ASR 或 LLM 提供商,语音输入和润色暂时无法正常工作。
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 18 }}>
|
||||
<button
|
||||
onClick={onLater}
|
||||
style={{
|
||||
height: 32,
|
||||
padding: '0 13px',
|
||||
borderRadius: 8,
|
||||
border: '0.5px solid var(--ol-line-strong)',
|
||||
background: 'var(--ol-surface)',
|
||||
color: 'var(--ol-ink-3)',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 12.5,
|
||||
fontWeight: 500,
|
||||
cursor: 'default',
|
||||
}}
|
||||
>
|
||||
稍后
|
||||
</button>
|
||||
<button
|
||||
onClick={onOpenSettings}
|
||||
style={{
|
||||
height: 32,
|
||||
padding: '0 14px',
|
||||
borderRadius: 8,
|
||||
border: 0,
|
||||
background: 'var(--ol-ink)',
|
||||
color: '#fff',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 12.5,
|
||||
fontWeight: 500,
|
||||
cursor: 'default',
|
||||
}}
|
||||
>
|
||||
去设置
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -64,6 +64,9 @@ export function Onboarding({ onComplete }: OnboardingProps) {
|
||||
} else {
|
||||
const status = await requestMicrophonePermission();
|
||||
setMicrophone(status);
|
||||
if (status === 'denied' || status === 'restricted') {
|
||||
await openSystemSettings('microphone');
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setBusy(false);
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
|
||||
import { useEffect, useState, type CSSProperties } from 'react';
|
||||
import { Icon } from './Icon';
|
||||
import { Settings as SettingsContent } from '../pages/Settings';
|
||||
import { APP_VERSION_LABEL } from '../lib/appVersion';
|
||||
import { Settings as SettingsContent, type SettingsSectionId } from '../pages/Settings';
|
||||
import { Row } from './ui/Row';
|
||||
import { SegSimple } from './ui/SegSimple';
|
||||
import { SwitchLite } from './ui/SwitchLite';
|
||||
@@ -14,6 +15,7 @@ import type { OS } from './WindowChrome';
|
||||
interface SettingsModalProps {
|
||||
os: OS;
|
||||
onClose: () => void;
|
||||
initialSettingsSection?: SettingsSectionId;
|
||||
}
|
||||
|
||||
type ModalSectionId = '账户' | '设置' | '个性化' | '关于';
|
||||
@@ -28,7 +30,7 @@ interface ModalGroup {
|
||||
items: ModalNavItem[];
|
||||
}
|
||||
|
||||
export function SettingsModal({ os: _os, onClose }: SettingsModalProps) {
|
||||
export function SettingsModal({ os: _os, onClose, initialSettingsSection }: SettingsModalProps) {
|
||||
const [section, setSection] = useState<ModalSectionId>('设置');
|
||||
const groups: ModalGroup[] = [
|
||||
{ items: [{ id: '账户', icon: 'user' }, { id: '设置', icon: 'settings' }, { id: '个性化', icon: 'sparkle' }, { id: '关于', icon: 'info' }] },
|
||||
@@ -119,7 +121,7 @@ export function SettingsModal({ os: _os, onClose }: SettingsModalProps) {
|
||||
|
||||
<h2 style={{ margin: '0 0 18px', fontSize: 22, fontWeight: 600, letterSpacing: '-0.02em' }}>{section}</h2>
|
||||
|
||||
{section === '设置' && <SettingsContent embedded />}
|
||||
{section === '设置' && <SettingsContent embedded initialSection={initialSettingsSection} />}
|
||||
{section === '账户' && <AccountSection />}
|
||||
{section === '个性化' && <PersonalizeSection />}
|
||||
{section === '关于' && <AboutMini />}
|
||||
@@ -222,7 +224,7 @@ function AboutMini() {
|
||||
<img src="AppIcon.png" alt="" style={{ width: 56, height: 56, borderRadius: 13, boxShadow: '0 4px 10px rgba(0,0,0,.10), 0 0 0 0.5px rgba(0,0,0,.06)' }} />
|
||||
<div>
|
||||
<div style={{ fontSize: 17, fontWeight: 600 }}>OpenLess</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--ol-ink-3)' }}>自然说话,完美书写 · v1.0.0 (Build 412)</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--ol-ink-3)' }}>自然说话,完美书写 · {APP_VERSION_LABEL}</div>
|
||||
</div>
|
||||
</div>
|
||||
<Row label="检查更新"><button style={btnGhost}>检查</button></Row>
|
||||
|
||||
@@ -92,18 +92,21 @@ function WinTitleBar({ title }: WinTitleBarProps) {
|
||||
zIndex: 5,
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', padding: '0 14px', gap: 10 }}>
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
style={{ flex: 1, display: 'flex', alignItems: 'center', padding: '0 14px', gap: 10 }}
|
||||
>
|
||||
<img src="AppIcon.png" alt="" style={{ width: 14, height: 14, borderRadius: 3 }} />
|
||||
<span style={{ fontSize: 12, color: 'var(--ol-ink-3)', fontWeight: 500 }}>{title}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex' }}>
|
||||
<button style={winBtnStyle}>
|
||||
<button style={winBtnStyle} title="最小化" onClick={() => runWindowsWindowAction('minimize')}>
|
||||
<svg width="10" height="10" viewBox="0 0 10 10"><path d="M0 5h10" stroke="currentColor" strokeWidth="1" /></svg>
|
||||
</button>
|
||||
<button style={winBtnStyle}>
|
||||
<button style={winBtnStyle} title="最大化" onClick={() => runWindowsWindowAction('toggleMaximize')}>
|
||||
<svg width="10" height="10" viewBox="0 0 10 10"><rect x="0.5" y="0.5" width="9" height="9" stroke="currentColor" strokeWidth="1" fill="none" /></svg>
|
||||
</button>
|
||||
<button style={winBtnStyle}>
|
||||
<button style={winCloseBtnStyle} title="关闭" onClick={() => runWindowsWindowAction('close')}>
|
||||
<svg width="10" height="10" viewBox="0 0 10 10"><path d="M0 0L10 10M10 0L0 10" stroke="currentColor" strokeWidth="1" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
@@ -111,6 +114,22 @@ function WinTitleBar({ title }: WinTitleBarProps) {
|
||||
);
|
||||
}
|
||||
|
||||
async function runWindowsWindowAction(action: 'minimize' | 'toggleMaximize' | 'close') {
|
||||
try {
|
||||
const { getCurrentWindow } = await import('@tauri-apps/api/window');
|
||||
const currentWindow = getCurrentWindow();
|
||||
if (action === 'minimize') {
|
||||
await currentWindow.minimize();
|
||||
} else if (action === 'toggleMaximize') {
|
||||
await currentWindow.toggleMaximize();
|
||||
} else {
|
||||
await currentWindow.close();
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`[window] Windows title button ${action} failed`, error);
|
||||
}
|
||||
}
|
||||
|
||||
const winBtnStyle: CSSProperties = {
|
||||
width: 46,
|
||||
height: '100%',
|
||||
@@ -122,3 +141,8 @@ const winBtnStyle: CSSProperties = {
|
||||
color: 'var(--ol-ink-3)',
|
||||
cursor: 'default',
|
||||
};
|
||||
|
||||
const winCloseBtnStyle: CSSProperties = {
|
||||
...winBtnStyle,
|
||||
color: 'var(--ol-ink-3)',
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
import packageJson from '../../package.json';
|
||||
|
||||
export const APP_VERSION = packageJson.version;
|
||||
export const APP_VERSION_LABEL = `v${APP_VERSION}`;
|
||||
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
areProvidersConfigured,
|
||||
shouldShowProviderSetupPrompt,
|
||||
} from './providerSetup';
|
||||
|
||||
function assertEqual(actual: boolean, expected: boolean, name: string) {
|
||||
if (actual !== expected) {
|
||||
throw new Error(`${name}: expected ${expected}, got ${actual}`);
|
||||
}
|
||||
}
|
||||
|
||||
assertEqual(
|
||||
areProvidersConfigured({ volcengineConfigured: true, arkConfigured: true }),
|
||||
true,
|
||||
'configured when ASR and LLM are both ready',
|
||||
);
|
||||
|
||||
assertEqual(
|
||||
areProvidersConfigured({ volcengineConfigured: false, arkConfigured: true }),
|
||||
false,
|
||||
'not configured when ASR provider is missing',
|
||||
);
|
||||
|
||||
assertEqual(
|
||||
areProvidersConfigured({ volcengineConfigured: true, arkConfigured: false }),
|
||||
false,
|
||||
'not configured when LLM provider is missing',
|
||||
);
|
||||
|
||||
assertEqual(
|
||||
shouldShowProviderSetupPrompt(
|
||||
{ volcengineConfigured: false, arkConfigured: false },
|
||||
null,
|
||||
),
|
||||
true,
|
||||
'show first-run prompt when providers are missing and no prompt was seen',
|
||||
);
|
||||
|
||||
assertEqual(
|
||||
shouldShowProviderSetupPrompt(
|
||||
{ volcengineConfigured: false, arkConfigured: false },
|
||||
'1',
|
||||
),
|
||||
false,
|
||||
'do not repeat first-run prompt after the user has seen it',
|
||||
);
|
||||
|
||||
assertEqual(
|
||||
shouldShowProviderSetupPrompt(
|
||||
{ volcengineConfigured: true, arkConfigured: true },
|
||||
null,
|
||||
),
|
||||
false,
|
||||
'do not show prompt when providers are already configured',
|
||||
);
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { CredentialsStatus } from './types';
|
||||
|
||||
export const PROVIDER_SETUP_PROMPT_SEEN_KEY = 'ol.providerSetupPromptSeen';
|
||||
|
||||
export function areProvidersConfigured(credentials: CredentialsStatus): boolean {
|
||||
return credentials.volcengineConfigured && credentials.arkConfigured;
|
||||
}
|
||||
|
||||
export function shouldShowProviderSetupPrompt(
|
||||
credentials: CredentialsStatus,
|
||||
promptSeenValue: string | null,
|
||||
): boolean {
|
||||
return !areProvidersConfigured(credentials) && promptSeenValue !== '1';
|
||||
}
|
||||
@@ -4,12 +4,15 @@
|
||||
|
||||
import { useEffect, useState, type CSSProperties, type ReactNode } from 'react';
|
||||
import { Icon } from '../components/Icon';
|
||||
import { APP_VERSION_LABEL } from '../lib/appVersion';
|
||||
import {
|
||||
checkAccessibilityPermission,
|
||||
checkMicrophonePermission,
|
||||
getSettings,
|
||||
openSystemSettings,
|
||||
readCredential,
|
||||
requestAccessibilityPermission,
|
||||
requestMicrophonePermission,
|
||||
setCredential,
|
||||
setSettings,
|
||||
} from '../lib/ipc';
|
||||
@@ -18,13 +21,19 @@ import { Btn, Card, PageHeader, Pill } from './_atoms';
|
||||
|
||||
interface SettingsProps {
|
||||
embedded?: boolean;
|
||||
initialSection?: SettingsSectionId;
|
||||
}
|
||||
|
||||
type SectionId = '录音' | '提供商' | '快捷键' | '权限' | '关于';
|
||||
export type SettingsSectionId = '录音' | '提供商' | '快捷键' | '权限' | '关于';
|
||||
|
||||
export function Settings({ embedded = false, initialSection = '录音' }: SettingsProps) {
|
||||
const [section, setSection] = useState<SettingsSectionId>(initialSection);
|
||||
const sections: SettingsSectionId[] = ['录音', '提供商', '快捷键', '权限', '关于'];
|
||||
|
||||
useEffect(() => {
|
||||
setSection(initialSection);
|
||||
}, [initialSection]);
|
||||
|
||||
export function Settings({ embedded = false }: SettingsProps) {
|
||||
const [section, setSection] = useState<SectionId>('录音');
|
||||
const sections: SectionId[] = ['录音', '提供商', '快捷键', '权限', '关于'];
|
||||
return (
|
||||
<>
|
||||
{!embedded && (
|
||||
@@ -374,6 +383,20 @@ function PermissionsSection() {
|
||||
refresh();
|
||||
};
|
||||
|
||||
const reRequestMicrophone = async () => {
|
||||
if (microphone === 'denied' || microphone === 'restricted') {
|
||||
await openSystemSettings('microphone');
|
||||
refresh();
|
||||
return;
|
||||
}
|
||||
const status = await requestMicrophonePermission();
|
||||
setMicrophone(status);
|
||||
if (status === 'denied' || status === 'restricted') {
|
||||
await openSystemSettings('microphone');
|
||||
}
|
||||
refresh();
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 4 }}>权限</div>
|
||||
@@ -381,7 +404,14 @@ function PermissionsSection() {
|
||||
OpenLess 需要以下系统权限才能正常工作。授权后通常需要完全退出 App 重启一次才生效。
|
||||
</div>
|
||||
<SettingRow label="麦克风" desc="用于捕获你的语音输入。">
|
||||
<PermissionPill status={microphone} />
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<PermissionPill status={microphone} />
|
||||
{microphone !== 'granted' && microphone !== 'notApplicable' && microphone !== 'loading' && (
|
||||
<Btn variant="ghost" size="sm" onClick={reRequestMicrophone}>
|
||||
{microphone === 'denied' || microphone === 'restricted' ? '打开系统设置' : '授权'}
|
||||
</Btn>
|
||||
)}
|
||||
</div>
|
||||
</SettingRow>
|
||||
<SettingRow label="辅助功能" desc="用于监听全局快捷键并将识别结果写入光标位置。">
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
@@ -430,7 +460,7 @@ function AboutSection() {
|
||||
>OL</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 16, fontWeight: 600 }}>OpenLess</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--ol-ink-3)' }}>自然说话,完美书写 · v0.6.2 (Build 384)</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--ol-ink-3)' }}>自然说话,完美书写 · {APP_VERSION_LABEL}</div>
|
||||
</div>
|
||||
</div>
|
||||
<SettingRow label="检查更新"><Btn variant="ghost" size="sm">检查</Btn></SettingRow>
|
||||
|
||||
Reference in new issue
Block a user