From a6f787d7a4c2bea7bc60bb68fb48c2d7b71d4d82 Mon Sep 17 00:00:00 2001 From: ciddwd <2117971372@qq.com> Date: Sat, 6 Jun 2026 09:07:40 +0800 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20=E6=89=8B=E6=9C=BA=E8=BF=9C?= =?UTF-8?q?=E7=A8=8B=E5=BD=95=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .spec-workflow/templates/design-template.md | 96 +++ .spec-workflow/templates/product-template.md | 51 ++ .../templates/requirements-template.md | 50 ++ .../templates/structure-template.md | 145 ++++ .spec-workflow/templates/tasks-template.md | 139 ++++ .spec-workflow/templates/tech-template.md | 99 +++ .spec-workflow/user-templates/README.md | 64 ++ openless-all/app/src-tauri/Cargo.lock | 181 +++++ openless-all/app/src-tauri/Cargo.toml | 8 + openless-all/app/src-tauri/src/commands.rs | 30 + openless-all/app/src-tauri/src/coordinator.rs | 169 +++++ .../src-tauri/src/coordinator/dictation.rs | 51 +- openless-all/app/src-tauri/src/lib.rs | 6 + .../src-tauri/src/remote_server/assets/app.js | 718 ++++++++++++++++++ .../src/remote_server/assets/index.html | 87 +++ .../src/remote_server/assets/style.css | 367 +++++++++ .../app/src-tauri/src/remote_server/mod.rs | 439 +++++++++++ openless-all/app/src-tauri/src/types.rs | 41 + openless-all/app/src/i18n/en.ts | 15 + openless-all/app/src/i18n/ja.ts | 15 + openless-all/app/src/i18n/ko.ts | 15 + openless-all/app/src/i18n/zh-CN.ts | 15 + openless-all/app/src/i18n/zh-TW.ts | 15 + openless-all/app/src/lib/ipc.ts | 29 + openless-all/app/src/lib/stylePrefs.test.ts | 4 + openless-all/app/src/lib/types.ts | 8 + .../src/pages/settings/RemoteInputSection.tsx | 235 ++++++ openless-all/app/src/pages/settings/tabs.tsx | 2 + 28 files changed, 3080 insertions(+), 14 deletions(-) create mode 100644 .spec-workflow/templates/design-template.md create mode 100644 .spec-workflow/templates/product-template.md create mode 100644 .spec-workflow/templates/requirements-template.md create mode 100644 .spec-workflow/templates/structure-template.md create mode 100644 .spec-workflow/templates/tasks-template.md create mode 100644 .spec-workflow/templates/tech-template.md create mode 100644 .spec-workflow/user-templates/README.md create mode 100644 openless-all/app/src-tauri/src/remote_server/assets/app.js create mode 100644 openless-all/app/src-tauri/src/remote_server/assets/index.html create mode 100644 openless-all/app/src-tauri/src/remote_server/assets/style.css create mode 100644 openless-all/app/src-tauri/src/remote_server/mod.rs create mode 100644 openless-all/app/src/pages/settings/RemoteInputSection.tsx diff --git a/.spec-workflow/templates/design-template.md b/.spec-workflow/templates/design-template.md new file mode 100644 index 00000000..1295d7b4 --- /dev/null +++ b/.spec-workflow/templates/design-template.md @@ -0,0 +1,96 @@ +# Design Document + +## Overview + +[High-level description of the feature and its place in the overall system] + +## Steering Document Alignment + +### Technical Standards (tech.md) +[How the design follows documented technical patterns and standards] + +### Project Structure (structure.md) +[How the implementation will follow project organization conventions] + +## Code Reuse Analysis +[What existing code will be leveraged, extended, or integrated with this feature] + +### Existing Components to Leverage +- **[Component/Utility Name]**: [How it will be used] +- **[Service/Helper Name]**: [How it will be extended] + +### Integration Points +- **[Existing System/API]**: [How the new feature will integrate] +- **[Database/Storage]**: [How data will connect to existing schemas] + +## Architecture + +[Describe the overall architecture and design patterns used] + +### Modular Design Principles +- **Single File Responsibility**: Each file should handle one specific concern or domain +- **Component Isolation**: Create small, focused components rather than large monolithic files +- **Service Layer Separation**: Separate data access, business logic, and presentation layers +- **Utility Modularity**: Break utilities into focused, single-purpose modules + +```mermaid +graph TD + A[Component A] --> B[Component B] + B --> C[Component C] +``` + +## Components and Interfaces + +### Component 1 +- **Purpose:** [What this component does] +- **Interfaces:** [Public methods/APIs] +- **Dependencies:** [What it depends on] +- **Reuses:** [Existing components/utilities it builds upon] + +### Component 2 +- **Purpose:** [What this component does] +- **Interfaces:** [Public methods/APIs] +- **Dependencies:** [What it depends on] +- **Reuses:** [Existing components/utilities it builds upon] + +## Data Models + +### Model 1 +``` +[Define the structure of Model1 in your language] +- id: [unique identifier type] +- name: [string/text type] +- [Additional properties as needed] +``` + +### Model 2 +``` +[Define the structure of Model2 in your language] +- id: [unique identifier type] +- [Additional properties as needed] +``` + +## Error Handling + +### Error Scenarios +1. **Scenario 1:** [Description] + - **Handling:** [How to handle] + - **User Impact:** [What user sees] + +2. **Scenario 2:** [Description] + - **Handling:** [How to handle] + - **User Impact:** [What user sees] + +## Testing Strategy + +### Unit Testing +- [Unit testing approach] +- [Key components to test] + +### Integration Testing +- [Integration testing approach] +- [Key flows to test] + +### End-to-End Testing +- [E2E testing approach] +- [User scenarios to test] diff --git a/.spec-workflow/templates/product-template.md b/.spec-workflow/templates/product-template.md new file mode 100644 index 00000000..82e60de2 --- /dev/null +++ b/.spec-workflow/templates/product-template.md @@ -0,0 +1,51 @@ +# Product Overview + +## Product Purpose +[Describe the core purpose of this product/project. What problem does it solve?] + +## Target Users +[Who are the primary users of this product? What are their needs and pain points?] + +## Key Features +[List the main features that deliver value to users] + +1. **Feature 1**: [Description] +2. **Feature 2**: [Description] +3. **Feature 3**: [Description] + +## Business Objectives +[What are the business goals this product aims to achieve?] + +- [Objective 1] +- [Objective 2] +- [Objective 3] + +## Success Metrics +[How will we measure the success of this product?] + +- [Metric 1]: [Target] +- [Metric 2]: [Target] +- [Metric 3]: [Target] + +## Product Principles +[Core principles that guide product decisions] + +1. **[Principle 1]**: [Explanation] +2. **[Principle 2]**: [Explanation] +3. **[Principle 3]**: [Explanation] + +## Monitoring & Visibility (if applicable) +[How do users track progress and monitor the system?] + +- **Dashboard Type**: [e.g., Web-based, CLI, Desktop app] +- **Real-time Updates**: [e.g., WebSocket, polling, push notifications] +- **Key Metrics Displayed**: [What information is most important to surface] +- **Sharing Capabilities**: [e.g., read-only links, exports, reports] + +## Future Vision +[Where do we see this product evolving in the future?] + +### Potential Enhancements +- **Remote Access**: [e.g., Tunnel features for sharing dashboards with stakeholders] +- **Analytics**: [e.g., Historical trends, performance metrics] +- **Collaboration**: [e.g., Multi-user support, commenting] diff --git a/.spec-workflow/templates/requirements-template.md b/.spec-workflow/templates/requirements-template.md new file mode 100644 index 00000000..1c80ca0d --- /dev/null +++ b/.spec-workflow/templates/requirements-template.md @@ -0,0 +1,50 @@ +# Requirements Document + +## Introduction + +[Provide a brief overview of the feature, its purpose, and its value to users] + +## Alignment with Product Vision + +[Explain how this feature supports the goals outlined in product.md] + +## Requirements + +### Requirement 1 + +**User Story:** As a [role], I want [feature], so that [benefit] + +#### Acceptance Criteria + +1. WHEN [event] THEN [system] SHALL [response] +2. IF [precondition] THEN [system] SHALL [response] +3. WHEN [event] AND [condition] THEN [system] SHALL [response] + +### Requirement 2 + +**User Story:** As a [role], I want [feature], so that [benefit] + +#### Acceptance Criteria + +1. WHEN [event] THEN [system] SHALL [response] +2. IF [precondition] THEN [system] SHALL [response] + +## Non-Functional Requirements + +### Code Architecture and Modularity +- **Single Responsibility Principle**: Each file should have a single, well-defined purpose +- **Modular Design**: Components, utilities, and services should be isolated and reusable +- **Dependency Management**: Minimize interdependencies between modules +- **Clear Interfaces**: Define clean contracts between components and layers + +### Performance +- [Performance requirements] + +### Security +- [Security requirements] + +### Reliability +- [Reliability requirements] + +### Usability +- [Usability requirements] diff --git a/.spec-workflow/templates/structure-template.md b/.spec-workflow/templates/structure-template.md new file mode 100644 index 00000000..1ab1fbcc --- /dev/null +++ b/.spec-workflow/templates/structure-template.md @@ -0,0 +1,145 @@ +# Project Structure + +## Directory Organization + +``` +[Define your project's directory structure. Examples below - adapt to your project type] + +Example for a library/package: +project-root/ +├── src/ # Source code +├── tests/ # Test files +├── docs/ # Documentation +├── examples/ # Usage examples +└── [build/dist/out] # Build output + +Example for an application: +project-root/ +├── [src/app/lib] # Main source code +├── [assets/resources] # Static resources +├── [config/settings] # Configuration +├── [scripts/tools] # Build/utility scripts +└── [tests/spec] # Test files + +Common patterns: +- Group by feature/module +- Group by layer (UI, business logic, data) +- Group by type (models, controllers, views) +- Flat structure for simple projects +``` + +## Naming Conventions + +### Files +- **Components/Modules**: [e.g., `PascalCase`, `snake_case`, `kebab-case`] +- **Services/Handlers**: [e.g., `UserService`, `user_service`, `user-service`] +- **Utilities/Helpers**: [e.g., `dateUtils`, `date_utils`, `date-utils`] +- **Tests**: [e.g., `[filename]_test`, `[filename].test`, `[filename]Test`] + +### Code +- **Classes/Types**: [e.g., `PascalCase`, `CamelCase`, `snake_case`] +- **Functions/Methods**: [e.g., `camelCase`, `snake_case`, `PascalCase`] +- **Constants**: [e.g., `UPPER_SNAKE_CASE`, `SCREAMING_CASE`, `PascalCase`] +- **Variables**: [e.g., `camelCase`, `snake_case`, `lowercase`] + +## Import Patterns + +### Import Order +1. External dependencies +2. Internal modules +3. Relative imports +4. Style imports + +### Module/Package Organization +``` +[Describe your project's import/include patterns] +Examples: +- Absolute imports from project root +- Relative imports within modules +- Package/namespace organization +- Dependency management approach +``` + +## Code Structure Patterns + +[Define common patterns for organizing code within files. Below are examples - choose what applies to your project] + +### Module/Class Organization +``` +Example patterns: +1. Imports/includes/dependencies +2. Constants and configuration +3. Type/interface definitions +4. Main implementation +5. Helper/utility functions +6. Exports/public API +``` + +### Function/Method Organization +``` +Example patterns: +- Input validation first +- Core logic in the middle +- Error handling throughout +- Clear return points +``` + +### File Organization Principles +``` +Choose what works for your project: +- One class/module per file +- Related functionality grouped together +- Public API at the top/bottom +- Implementation details hidden +``` + +## Code Organization Principles + +1. **Single Responsibility**: Each file should have one clear purpose +2. **Modularity**: Code should be organized into reusable modules +3. **Testability**: Structure code to be easily testable +4. **Consistency**: Follow patterns established in the codebase + +## Module Boundaries +[Define how different parts of your project interact and maintain separation of concerns] + +Examples of boundary patterns: +- **Core vs Plugins**: Core functionality vs extensible plugins +- **Public API vs Internal**: What's exposed vs implementation details +- **Platform-specific vs Cross-platform**: OS-specific code isolation +- **Stable vs Experimental**: Production code vs experimental features +- **Dependencies direction**: Which modules can depend on which + +## Code Size Guidelines +[Define your project's guidelines for file and function sizes] + +Suggested guidelines: +- **File size**: [Define maximum lines per file] +- **Function/Method size**: [Define maximum lines per function] +- **Class/Module complexity**: [Define complexity limits] +- **Nesting depth**: [Maximum nesting levels] + +## Dashboard/Monitoring Structure (if applicable) +[How dashboard or monitoring components are organized] + +### Example Structure: +``` +src/ +└── dashboard/ # Self-contained dashboard subsystem + ├── server/ # Backend server components + ├── client/ # Frontend assets + ├── shared/ # Shared types/utilities + └── public/ # Static assets +``` + +### Separation of Concerns +- Dashboard isolated from core business logic +- Own CLI entry point for independent operation +- Minimal dependencies on main application +- Can be disabled without affecting core functionality + +## Documentation Standards +- All public APIs must have documentation +- Complex logic should include inline comments +- README files for major modules +- Follow language-specific documentation conventions diff --git a/.spec-workflow/templates/tasks-template.md b/.spec-workflow/templates/tasks-template.md new file mode 100644 index 00000000..be461de5 --- /dev/null +++ b/.spec-workflow/templates/tasks-template.md @@ -0,0 +1,139 @@ +# Tasks Document + +- [ ] 1. Create core interfaces in src/types/feature.ts + - File: src/types/feature.ts + - Define TypeScript interfaces for feature data structures + - Extend existing base interfaces from base.ts + - Purpose: Establish type safety for feature implementation + - _Leverage: src/types/base.ts_ + - _Requirements: 1.1_ + - _Prompt: Role: TypeScript Developer specializing in type systems and interfaces | Task: Create comprehensive TypeScript interfaces for the feature data structures following requirements 1.1, extending existing base interfaces from src/types/base.ts | Restrictions: Do not modify existing base interfaces, maintain backward compatibility, follow project naming conventions | Success: All interfaces compile without errors, proper inheritance from base types, full type coverage for feature requirements_ + +- [ ] 2. Create base model class in src/models/FeatureModel.ts + - File: src/models/FeatureModel.ts + - Implement base model extending BaseModel class + - Add validation methods using existing validation utilities + - Purpose: Provide data layer foundation for feature + - _Leverage: src/models/BaseModel.ts, src/utils/validation.ts_ + - _Requirements: 2.1_ + - _Prompt: Role: Backend Developer with expertise in Node.js and data modeling | Task: Create a base model class extending BaseModel and implementing validation following requirement 2.1, leveraging existing patterns from src/models/BaseModel.ts and src/utils/validation.ts | Restrictions: Must follow existing model patterns, do not bypass validation utilities, maintain consistent error handling | Success: Model extends BaseModel correctly, validation methods implemented and tested, follows project architecture patterns_ + +- [ ] 3. Add specific model methods to FeatureModel.ts + - File: src/models/FeatureModel.ts (continue from task 2) + - Implement create, update, delete methods + - Add relationship handling for foreign keys + - Purpose: Complete model functionality for CRUD operations + - _Leverage: src/models/BaseModel.ts_ + - _Requirements: 2.2, 2.3_ + - _Prompt: Role: Backend Developer with expertise in ORM and database operations | Task: Implement CRUD methods and relationship handling in FeatureModel.ts following requirements 2.2 and 2.3, extending patterns from src/models/BaseModel.ts | Restrictions: Must maintain transaction integrity, follow existing relationship patterns, do not duplicate base model functionality | Success: All CRUD operations work correctly, relationships are properly handled, database operations are atomic and efficient_ + +- [ ] 4. Create model unit tests in tests/models/FeatureModel.test.ts + - File: tests/models/FeatureModel.test.ts + - Write tests for model validation and CRUD methods + - Use existing test utilities and fixtures + - Purpose: Ensure model reliability and catch regressions + - _Leverage: tests/helpers/testUtils.ts, tests/fixtures/data.ts_ + - _Requirements: 2.1, 2.2_ + - _Prompt: Role: QA Engineer with expertise in unit testing and Jest/Mocha frameworks | Task: Create comprehensive unit tests for FeatureModel validation and CRUD methods covering requirements 2.1 and 2.2, using existing test utilities from tests/helpers/testUtils.ts and fixtures from tests/fixtures/data.ts | Restrictions: Must test both success and failure scenarios, do not test external dependencies directly, maintain test isolation | Success: All model methods are tested with good coverage, edge cases covered, tests run independently and consistently_ + +- [ ] 5. Create service interface in src/services/IFeatureService.ts + - File: src/services/IFeatureService.ts + - Define service contract with method signatures + - Extend base service interface patterns + - Purpose: Establish service layer contract for dependency injection + - _Leverage: src/services/IBaseService.ts_ + - _Requirements: 3.1_ + - _Prompt: Role: Software Architect specializing in service-oriented architecture and TypeScript interfaces | Task: Design service interface contract following requirement 3.1, extending base service patterns from src/services/IBaseService.ts for dependency injection | Restrictions: Must maintain interface segregation principle, do not expose internal implementation details, ensure contract compatibility with DI container | Success: Interface is well-defined with clear method signatures, extends base service appropriately, supports all required service operations_ + +- [ ] 6. Implement feature service in src/services/FeatureService.ts + - File: src/services/FeatureService.ts + - Create concrete service implementation using FeatureModel + - Add error handling with existing error utilities + - Purpose: Provide business logic layer for feature operations + - _Leverage: src/services/BaseService.ts, src/utils/errorHandler.ts, src/models/FeatureModel.ts_ + - _Requirements: 3.2_ + - _Prompt: Role: Backend Developer with expertise in service layer architecture and business logic | Task: Implement concrete FeatureService following requirement 3.2, using FeatureModel and extending BaseService patterns with proper error handling from src/utils/errorHandler.ts | Restrictions: Must implement interface contract exactly, do not bypass model validation, maintain separation of concerns from data layer | Success: Service implements all interface methods correctly, robust error handling implemented, business logic is well-encapsulated and testable_ + +- [ ] 7. Add service dependency injection in src/utils/di.ts + - File: src/utils/di.ts (modify existing) + - Register FeatureService in dependency injection container + - Configure service lifetime and dependencies + - Purpose: Enable service injection throughout application + - _Leverage: existing DI configuration in src/utils/di.ts_ + - _Requirements: 3.1_ + - _Prompt: Role: DevOps Engineer with expertise in dependency injection and IoC containers | Task: Register FeatureService in DI container following requirement 3.1, configuring appropriate lifetime and dependencies using existing patterns from src/utils/di.ts | Restrictions: Must follow existing DI container patterns, do not create circular dependencies, maintain service resolution efficiency | Success: FeatureService is properly registered and resolvable, dependencies are correctly configured, service lifetime is appropriate for use case_ + +- [ ] 8. Create service unit tests in tests/services/FeatureService.test.ts + - File: tests/services/FeatureService.test.ts + - Write tests for service methods with mocked dependencies + - Test error handling scenarios + - Purpose: Ensure service reliability and proper error handling + - _Leverage: tests/helpers/testUtils.ts, tests/mocks/modelMocks.ts_ + - _Requirements: 3.2, 3.3_ + - _Prompt: Role: QA Engineer with expertise in service testing and mocking frameworks | Task: Create comprehensive unit tests for FeatureService methods covering requirements 3.2 and 3.3, using mocked dependencies from tests/mocks/modelMocks.ts and test utilities | Restrictions: Must mock all external dependencies, test business logic in isolation, do not test framework code | Success: All service methods tested with proper mocking, error scenarios covered, tests verify business logic correctness and error handling_ + +- [ ] 4. Create API endpoints + - Design API structure + - _Leverage: src/api/baseApi.ts, src/utils/apiUtils.ts_ + - _Requirements: 4.0_ + - _Prompt: Role: API Architect specializing in RESTful design and Express.js | Task: Design comprehensive API structure following requirement 4.0, leveraging existing patterns from src/api/baseApi.ts and utilities from src/utils/apiUtils.ts | Restrictions: Must follow REST conventions, maintain API versioning compatibility, do not expose internal data structures directly | Success: API structure is well-designed and documented, follows existing patterns, supports all required operations with proper HTTP methods and status codes_ + +- [ ] 4.1 Set up routing and middleware + - Configure application routes + - Add authentication middleware + - Set up error handling middleware + - _Leverage: src/middleware/auth.ts, src/middleware/errorHandler.ts_ + - _Requirements: 4.1_ + - _Prompt: Role: Backend Developer with expertise in Express.js middleware and routing | Task: Configure application routes and middleware following requirement 4.1, integrating authentication from src/middleware/auth.ts and error handling from src/middleware/errorHandler.ts | Restrictions: Must maintain middleware order, do not bypass security middleware, ensure proper error propagation | Success: Routes are properly configured with correct middleware chain, authentication works correctly, errors are handled gracefully throughout the request lifecycle_ + +- [ ] 4.2 Implement CRUD endpoints + - Create API endpoints + - Add request validation + - Write API integration tests + - _Leverage: src/controllers/BaseController.ts, src/utils/validation.ts_ + - _Requirements: 4.2, 4.3_ + - _Prompt: Role: Full-stack Developer with expertise in API development and validation | Task: Implement CRUD endpoints following requirements 4.2 and 4.3, extending BaseController patterns and using validation utilities from src/utils/validation.ts | Restrictions: Must validate all inputs, follow existing controller patterns, ensure proper HTTP status codes and responses | Success: All CRUD operations work correctly, request validation prevents invalid data, integration tests pass and cover all endpoints_ + +- [ ] 5. Add frontend components + - Plan component architecture + - _Leverage: src/components/BaseComponent.tsx, src/styles/theme.ts_ + - _Requirements: 5.0_ + - _Prompt: Role: Frontend Architect with expertise in React component design and architecture | Task: Plan comprehensive component architecture following requirement 5.0, leveraging base patterns from src/components/BaseComponent.tsx and theme system from src/styles/theme.ts | Restrictions: Must follow existing component patterns, maintain design system consistency, ensure component reusability | Success: Architecture is well-planned and documented, components are properly organized, follows existing patterns and theme system_ + +- [ ] 5.1 Create base UI components + - Set up component structure + - Implement reusable components + - Add styling and theming + - _Leverage: src/components/BaseComponent.tsx, src/styles/theme.ts_ + - _Requirements: 5.1_ + - _Prompt: Role: Frontend Developer specializing in React and component architecture | Task: Create reusable UI components following requirement 5.1, extending BaseComponent patterns and using existing theme system from src/styles/theme.ts | Restrictions: Must use existing theme variables, follow component composition patterns, ensure accessibility compliance | Success: Components are reusable and properly themed, follow existing architecture, accessible and responsive_ + +- [ ] 5.2 Implement feature-specific components + - Create feature components + - Add state management + - Connect to API endpoints + - _Leverage: src/hooks/useApi.ts, src/components/BaseComponent.tsx_ + - _Requirements: 5.2, 5.3_ + - _Prompt: Role: React Developer with expertise in state management and API integration | Task: Implement feature-specific components following requirements 5.2 and 5.3, using API hooks from src/hooks/useApi.ts and extending BaseComponent patterns | Restrictions: Must use existing state management patterns, handle loading and error states properly, maintain component performance | Success: Components are fully functional with proper state management, API integration works smoothly, user experience is responsive and intuitive_ + +- [ ] 6. Integration and testing + - Plan integration approach + - _Leverage: src/utils/integrationUtils.ts, tests/helpers/testUtils.ts_ + - _Requirements: 6.0_ + - _Prompt: Role: Integration Engineer with expertise in system integration and testing strategies | Task: Plan comprehensive integration approach following requirement 6.0, leveraging integration utilities from src/utils/integrationUtils.ts and test helpers | Restrictions: Must consider all system components, ensure proper test coverage, maintain integration test reliability | Success: Integration plan is comprehensive and feasible, all system components work together correctly, integration points are well-tested_ + +- [ ] 6.1 Write end-to-end tests + - Set up E2E testing framework + - Write user journey tests + - Add test automation + - _Leverage: tests/helpers/testUtils.ts, tests/fixtures/data.ts_ + - _Requirements: All_ + - _Prompt: Role: QA Automation Engineer with expertise in E2E testing and test frameworks like Cypress or Playwright | Task: Implement comprehensive end-to-end tests covering all requirements, setting up testing framework and user journey tests using test utilities and fixtures | Restrictions: Must test real user workflows, ensure tests are maintainable and reliable, do not test implementation details | Success: E2E tests cover all critical user journeys, tests run reliably in CI/CD pipeline, user experience is validated from end-to-end_ + +- [ ] 6.2 Final integration and cleanup + - Integrate all components + - Fix any integration issues + - Clean up code and documentation + - _Leverage: src/utils/cleanup.ts, docs/templates/_ + - _Requirements: All_ + - _Prompt: Role: Senior Developer with expertise in code quality and system integration | Task: Complete final integration of all components and perform comprehensive cleanup covering all requirements, using cleanup utilities and documentation templates | Restrictions: Must not break existing functionality, ensure code quality standards are met, maintain documentation consistency | Success: All components are fully integrated and working together, code is clean and well-documented, system meets all requirements and quality standards_ diff --git a/.spec-workflow/templates/tech-template.md b/.spec-workflow/templates/tech-template.md new file mode 100644 index 00000000..57cd538d --- /dev/null +++ b/.spec-workflow/templates/tech-template.md @@ -0,0 +1,99 @@ +# Technology Stack + +## Project Type +[Describe what kind of project this is: web application, CLI tool, desktop application, mobile app, library, API service, embedded system, game, etc.] + +## Core Technologies + +### Primary Language(s) +- **Language**: [e.g., Python 3.11, Go 1.21, TypeScript, Rust, C++] +- **Runtime/Compiler**: [if applicable] +- **Language-specific tools**: [package managers, build tools, etc.] + +### Key Dependencies/Libraries +[List the main libraries and frameworks your project depends on] +- **[Library/Framework name]**: [Purpose and version] +- **[Library/Framework name]**: [Purpose and version] + +### Application Architecture +[Describe how your application is structured - this could be MVC, event-driven, plugin-based, client-server, standalone, microservices, monolithic, etc.] + +### Data Storage (if applicable) +- **Primary storage**: [e.g., PostgreSQL, files, in-memory, cloud storage] +- **Caching**: [e.g., Redis, in-memory, disk cache] +- **Data formats**: [e.g., JSON, Protocol Buffers, XML, binary] + +### External Integrations (if applicable) +- **APIs**: [External services you integrate with] +- **Protocols**: [e.g., HTTP/REST, gRPC, WebSocket, TCP/IP] +- **Authentication**: [e.g., OAuth, API keys, certificates] + +### Monitoring & Dashboard Technologies (if applicable) +- **Dashboard Framework**: [e.g., React, Vue, vanilla JS, terminal UI] +- **Real-time Communication**: [e.g., WebSocket, Server-Sent Events, polling] +- **Visualization Libraries**: [e.g., Chart.js, D3, terminal graphs] +- **State Management**: [e.g., Redux, Vuex, file system as source of truth] + +## Development Environment + +### Build & Development Tools +- **Build System**: [e.g., Make, CMake, Gradle, npm scripts, cargo] +- **Package Management**: [e.g., pip, npm, cargo, go mod, apt, brew] +- **Development workflow**: [e.g., hot reload, watch mode, REPL] + +### Code Quality Tools +- **Static Analysis**: [Tools for code quality and correctness] +- **Formatting**: [Code style enforcement tools] +- **Testing Framework**: [Unit, integration, and/or end-to-end testing tools] +- **Documentation**: [Documentation generation tools] + +### Version Control & Collaboration +- **VCS**: [e.g., Git, Mercurial, SVN] +- **Branching Strategy**: [e.g., Git Flow, GitHub Flow, trunk-based] +- **Code Review Process**: [How code reviews are conducted] + +### Dashboard Development (if applicable) +- **Live Reload**: [e.g., Hot module replacement, file watchers] +- **Port Management**: [e.g., Dynamic allocation, configurable ports] +- **Multi-Instance Support**: [e.g., Running multiple dashboards simultaneously] + +## Deployment & Distribution (if applicable) +- **Target Platform(s)**: [Where/how the project runs: cloud, on-premise, desktop, mobile, embedded] +- **Distribution Method**: [How users get your software: download, package manager, app store, SaaS] +- **Installation Requirements**: [Prerequisites, system requirements] +- **Update Mechanism**: [How updates are delivered] + +## Technical Requirements & Constraints + +### Performance Requirements +- [e.g., response time, throughput, memory usage, startup time] +- [Specific benchmarks or targets] + +### Compatibility Requirements +- **Platform Support**: [Operating systems, architectures, versions] +- **Dependency Versions**: [Minimum/maximum versions of dependencies] +- **Standards Compliance**: [Industry standards, protocols, specifications] + +### Security & Compliance +- **Security Requirements**: [Authentication, encryption, data protection] +- **Compliance Standards**: [GDPR, HIPAA, SOC2, etc. if applicable] +- **Threat Model**: [Key security considerations] + +### Scalability & Reliability +- **Expected Load**: [Users, requests, data volume] +- **Availability Requirements**: [Uptime targets, disaster recovery] +- **Growth Projections**: [How the system needs to scale] + +## Technical Decisions & Rationale +[Document key architectural and technology choices] + +### Decision Log +1. **[Technology/Pattern Choice]**: [Why this was chosen, alternatives considered] +2. **[Architecture Decision]**: [Rationale, trade-offs accepted] +3. **[Tool/Library Selection]**: [Reasoning, evaluation criteria] + +## Known Limitations +[Document any technical debt, limitations, or areas for improvement] + +- [Limitation 1]: [Impact and potential future solutions] +- [Limitation 2]: [Why it exists and when it might be addressed] diff --git a/.spec-workflow/user-templates/README.md b/.spec-workflow/user-templates/README.md new file mode 100644 index 00000000..ad36a48b --- /dev/null +++ b/.spec-workflow/user-templates/README.md @@ -0,0 +1,64 @@ +# User Templates + +This directory allows you to create custom templates that override the default Spec Workflow templates. + +## How to Use Custom Templates + +1. **Create your custom template file** in this directory with the exact same name as the default template you want to override: + - `requirements-template.md` - Override requirements document template + - `design-template.md` - Override design document template + - `tasks-template.md` - Override tasks document template + - `product-template.md` - Override product steering template + - `tech-template.md` - Override tech steering template + - `structure-template.md` - Override structure steering template + +2. **Template Loading Priority**: + - The system first checks this `user-templates/` directory + - If a matching template is found here, it will be used + - Otherwise, the default template from `templates/` will be used + +## Example Custom Template + +To create a custom requirements template: + +1. Create a file named `requirements-template.md` in this directory +2. Add your custom structure, for example: + +```markdown +# Requirements Document + +## Executive Summary +[Your custom section] + +## Business Requirements +[Your custom structure] + +## Technical Requirements +[Your custom fields] + +## Custom Sections +[Add any sections specific to your workflow] +``` + +## Template Variables + +Templates can include placeholders that will be replaced when documents are created: +- `{{projectName}}` - The name of your project +- `{{featureName}}` - The name of the feature being specified +- `{{date}}` - The current date +- `{{author}}` - The document author + +## Best Practices + +1. **Start from defaults**: Copy a default template from `../templates/` as a starting point +2. **Keep structure consistent**: Maintain similar section headers for tool compatibility +3. **Document changes**: Add comments explaining why sections were added/modified +4. **Version control**: Track your custom templates in version control +5. **Test thoroughly**: Ensure custom templates work with the spec workflow tools + +## Notes + +- Custom templates are project-specific and not included in the package distribution +- The `templates/` directory contains the default templates which are updated with each version +- Your custom templates in this directory are preserved during updates +- If a custom template has errors, the system will fall back to the default template diff --git a/openless-all/app/src-tauri/Cargo.lock b/openless-all/app/src-tauri/Cargo.lock index b04c5855..9eada71e 100644 --- a/openless-all/app/src-tauri/Cargo.lock +++ b/openless-all/app/src-tauri/Cargo.lock @@ -349,6 +349,60 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "base64 0.22.1", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_urlencoded", + "sha1", + "sync_wrapper", + "tokio", + "tokio-tungstenite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", +] + [[package]] name = "base64" version = "0.21.7" @@ -1972,6 +2026,18 @@ dependencies = [ "wasip3", ] +[[package]] +name = "getset" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cf0fc11e47561d47397154977bc219f4cf809b2974facc3ccb3b89e2436f912" +dependencies = [ + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "gio" version = "0.18.4" @@ -2278,6 +2344,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hyper" version = "1.9.0" @@ -2292,6 +2364,7 @@ dependencies = [ "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -2919,6 +2992,17 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +[[package]] +name = "local-ip-address" +version = "0.6.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa08fb2b1ec3ea84575e94b489d06d4ce0cbf052d12acd515838f50e3c3d63e3" +dependencies = [ + "libc", + "neli", + "windows-sys 0.61.2", +] + [[package]] name = "lock_api" version = "0.4.14" @@ -2981,6 +3065,12 @@ dependencies = [ "web_atoms", ] +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + [[package]] name = "memchr" version = "2.8.0" @@ -3175,6 +3265,35 @@ dependencies = [ "jni-sys 0.3.1", ] +[[package]] +name = "neli" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f9786d56d972959e1408b6a93be6af13b9c1392036c5c1fafa08a1b0c6ee87" +dependencies = [ + "bitflags 2.11.1", + "byteorder", + "derive_builder", + "getset", + "libc", + "log", + "neli-proc-macros", + "parking_lot", +] + +[[package]] +name = "neli-proc-macros" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05d8d08c6e98f20a62417478ebf7be8e1425ec9acecc6f63e22da633f6b71609" +dependencies = [ + "either", + "proc-macro2", + "quote", + "serde", + "syn 2.0.117", +] + [[package]] name = "new_debug_unreachable" version = "1.0.6" @@ -3680,6 +3799,7 @@ version = "1.3.6-2" dependencies = [ "anyhow", "arboard", + "axum", "base64 0.22.1", "block2 0.5.1", "bytes", @@ -3696,8 +3816,11 @@ dependencies = [ "foundry-local-sdk", "futures-util", "global-hotkey", + "hyper", + "hyper-util", "keyring", "libc", + "local-ip-address", "log", "objc2 0.5.2", "objc2-app-kit 0.2.2", @@ -3705,7 +3828,9 @@ dependencies = [ "once_cell", "parking_lot", "raw-window-handle", + "rcgen", "reqwest 0.12.28", + "rustls", "serde", "serde_json", "sha2", @@ -3721,7 +3846,9 @@ dependencies = [ "tauri-plugin-updater", "thiserror 1.0.69", "tokio", + "tokio-rustls", "tokio-tungstenite", + "tower", "url", "uuid", "window-vibrancy 0.7.1", @@ -3883,6 +4010,16 @@ dependencies = [ "hmac", ] +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -4174,6 +4311,28 @@ dependencies = [ "version_check", ] +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -4374,6 +4533,19 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" +[[package]] +name = "rcgen" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "yasna", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -7781,6 +7953,15 @@ dependencies = [ "lzma-sys", ] +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] + [[package]] name = "yoke" version = "0.8.2" diff --git a/openless-all/app/src-tauri/Cargo.toml b/openless-all/app/src-tauri/Cargo.toml index 1779b069..5d286ec2 100644 --- a/openless-all/app/src-tauri/Cargo.toml +++ b/openless-all/app/src-tauri/Cargo.toml @@ -55,6 +55,14 @@ global-hotkey = "0.6" cpal = "0.15" enigo = "0.2" arboard = "3" +rcgen = "^0.13" +local-ip-address = "^0.6" +rustls = { version = "^0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] } +tokio-rustls = { version = "^0.26", default-features = false, features = ["ring", "tls12", "logging"] } +axum = { version = "^0.7", default-features = false, features = ["ws", "http1", "tokio", "query"] } +hyper = { version = "^1", features = ["server", "http1"] } +hyper-util = { version = "^0.1", features = ["tokio", "server-auto", "server", "http1"] } +tower = { version = "^0.5", features = ["util"] } [target.'cfg(target_os = "macos")'.dependencies.keyring] version = "3.6.3" diff --git a/openless-all/app/src-tauri/src/commands.rs b/openless-all/app/src-tauri/src/commands.rs index 79a8f5b4..c727c077 100644 --- a/openless-all/app/src-tauri/src/commands.rs +++ b/openless-all/app/src-tauri/src/commands.rs @@ -228,6 +228,8 @@ pub fn set_settings( tray_microphones: State<'_, TrayMicrophoneMenuState>, mut prefs: UserPreferences, ) -> Result<(), String> { + // 捕获旧值用于远程输入服务的 diff(persist 后端口/开关变化时启停/重启)。 + let remote_prev = coord.prefs().get(); let packs = coord.style_packs().list().map_err(|e| e.to_string())?; sync_style_pack_preferences(&mut prefs, &packs); // 广播给所有 webview。issue #205:QaPanel 跑在独立 webview, @@ -254,6 +256,12 @@ pub fn set_settings( // 但函数签名保留 State 入参,以便 Tauri 在调用前注入。 let _ = tray_microphones; let _ = app.emit("prefs:changed", &prefs); + // 远程输入:开关 / 端口变化时启停或重启服务(PIN 变化走 regenerate_remote_pin 命令)。 + if remote_prev.remote_input_enabled != prefs.remote_input_enabled + || remote_prev.remote_input_port != prefs.remote_input_port + { + coord.refresh_remote_server(); + } Ok(()) } @@ -271,6 +279,28 @@ fn emit_prefs_changed(app: &AppHandle, prefs: &UserPreferences) { let _ = app.emit_to("main", "prefs:changed", prefs); } +// ─────────────────────────── 远程输入(局域网手机录音)─────────────────────────── + +#[tauri::command] +pub fn get_remote_input_status( + coord: CoordinatorState<'_>, +) -> crate::remote_server::RemoteInputStatus { + coord.remote_input_status() +} + +#[tauri::command] +pub fn list_local_ips() -> Vec { + crate::remote_server::local_lan_ipv4s() + .iter() + .map(|ip| ip.to_string()) + .collect() +} + +#[tauri::command] +pub fn regenerate_remote_pin(coord: CoordinatorState<'_>) -> String { + coord.regenerate_remote_pin() +} + pub(crate) fn sync_style_pack_prefs_and_persist( coord: &Coordinator, app: &AppHandle, diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index f8c58e6c..24e7a2eb 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -273,6 +273,17 @@ struct Inner { /// supervisor 线程,但 integration test 和未来 RunEvent::Exit 钩子需要这条 /// 显式退出路径。审计 3.1.2。 shutdown: AtomicBool, + // ── 远程输入(局域网手机录音)───────────────────────────── + /// true = 当前 begin_session 应跳过本地 cpal,改用手机经 WS 推来的 PCM。 + /// 由 Coordinator::start_remote_dictation 在 begin_session 前置位。 + remote_source_active: AtomicBool, + /// 远程会话的音频入口:begin_session 把组装好的 AudioConsumer 存这里, + /// WS server 收到手机 PCM 时取出 consume_pcm_chunk。等价于本地 cpal 喂 recorder。 + remote_audio_sink: Mutex>>, + /// 远程输入 HTTPS+WS 服务句柄。None = 未启动。 + remote_server: Mutex>, + /// 当前远程输入配对码(6 位数字)。进程内有效,不持久化(每次启动可轮换)。 + remote_pin: Mutex>, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -342,6 +353,10 @@ impl Coordinator { qa_stream_cancelled: Arc::new(AtomicBool::new(false)), local_asr_cache: Arc::new(crate::asr::local::LocalAsrCache::new()), shutdown: AtomicBool::new(false), + remote_source_active: AtomicBool::new(false), + remote_audio_sink: Mutex::new(None), + remote_server: Mutex::new(None), + remote_pin: Mutex::new(None), }), } } @@ -406,6 +421,10 @@ impl Coordinator { foundry_local_runtime, sherpa_onnx_runtime, shutdown: AtomicBool::new(false), + remote_source_active: AtomicBool::new(false), + remote_audio_sink: Mutex::new(None), + remote_server: Mutex::new(None), + remote_pin: Mutex::new(None), }), } } @@ -932,6 +951,156 @@ impl Coordinator { cancel_session(&self.inner); } + // ───────────────────────── 远程输入(局域网手机录音)───────────────────────── + // 把"远程输入"实现为一次普通听写会话,只是音频源换成手机经 WS 推来的 PCM: + // 完整复用 begin_session / end_session / cancel_session(一行不改)。本地与远程 + // 共用 inner.state,天然互斥。详见 dictation::start_recorder_for_starting 的远程分支。 + + /// 手机点"开始录音"。本地听写正在进行(phase != Idle)则拒绝并回 "busy"; + /// 否则置位 remote 标志后走 begin_session(内部跳过 cpal,把 consumer 存进 sink)。 + pub async fn start_remote_dictation(&self) -> Result<(), String> { + if !matches!(self.inner.state.lock().phase, SessionPhase::Idle) { + return Err("busy".into()); + } + self.inner + .remote_source_active + .store(true, Ordering::SeqCst); + let r = begin_session(&self.inner).await; + if r.is_err() { + self.clear_remote_source(); + } + r + } + + /// WS 每收到一帧二进制 PCM 调一次。仅 Starting/Listening 阶段转发给已组装的 + /// consumer(流式 ASR 的 DeferredAsrBridge 在 attach 前自缓冲,不丢早期音频)。 + pub fn feed_remote_pcm(&self, pcm: &[u8]) { + { + let phase = self.inner.state.lock().phase; + if phase != SessionPhase::Listening && phase != SessionPhase::Starting { + return; + } + } + let sink = self.inner.remote_audio_sink.lock().clone(); + if let Some(consumer) = sink { + consumer.consume_pcm_chunk(pcm); + } + } + + /// 手机点"停止"。Starting 阶段记 pending_stop(等启动完成自动收尾);否则走 + /// end_session(转写→润色→光标落字,与本地一致),随后清 remote 标志。 + pub async fn stop_remote_dictation(&self) -> Result<(), String> { + if self.inner.state.lock().phase == SessionPhase::Starting { + request_stop_during_starting(&self.inner, "remote stop"); + return Ok(()); + } + let r = end_session(&self.inner).await; + self.clear_remote_source(); + r + } + + /// 手机断连 / 点取消:丢弃本次,不落字。 + pub fn cancel_remote_dictation(&self) { + cancel_session(&self.inner); + self.clear_remote_source(); + } + + fn clear_remote_source(&self) { + self.inner + .remote_source_active + .store(false, Ordering::SeqCst); + *self.inner.remote_audio_sink.lock() = None; + } + + /// 当前远程输入运行态(供命令/前端查询)。 + pub fn remote_input_status(&self) -> crate::remote_server::RemoteInputStatus { + let prefs = self.inner.prefs.get(); + let handle = self.inner.remote_server.lock(); + let running = handle.is_some(); + let port = handle + .as_ref() + .map(|h| h.bound_port) + .unwrap_or(prefs.remote_input_port); + let pin = self.inner.remote_pin.lock().clone().unwrap_or_default(); + let urls = if running { + crate::remote_server::access_urls(port) + } else { + Vec::new() + }; + crate::remote_server::RemoteInputStatus { + running, + port, + pin, + urls, + } + } + + /// 重新生成 6 位配对码并重启服务。 + pub fn regenerate_remote_pin(self: &Arc) -> String { + let pin = crate::remote_server::generate_pin(); + *self.inner.remote_pin.lock() = Some(pin.clone()); + self.refresh_remote_server(); + pin + } + + /// 按 prefs 启停 / 重启远程输入服务。在 setup 与 prefs 变更(端口/开关)时调用。 + pub fn refresh_remote_server(self: &Arc) { + let coord = Arc::clone(self); + tauri::async_runtime::spawn(async move { + // 先停旧(优雅关停) + let old = coord.inner.remote_server.lock().take(); + if let Some(handle) = old { + handle.shutdown().await; + } + let prefs = coord.inner.prefs.get(); + let app = coord.inner.app.lock().clone(); + if !prefs.remote_input_enabled { + if let Some(app) = &app { + let _ = + app.emit("remote-input:running", serde_json::json!({"running": false})); + } + return; + } + let Some(app) = app else { + return; + }; + // PIN:复用进程内的 remote_pin,缺则生成。 + let pin = { + let mut guard = coord.inner.remote_pin.lock(); + if guard.is_none() { + *guard = Some(crate::remote_server::generate_pin()); + } + guard.clone().unwrap_or_default() + }; + let port = prefs.remote_input_port; + match crate::remote_server::start(crate::remote_server::RemoteServerConfig { + port, + pin: pin.clone(), + coordinator: Arc::clone(&coord), + app: app.clone(), + }) + .await + { + Ok(handle) => { + let urls = crate::remote_server::access_urls(port); + *coord.inner.remote_server.lock() = Some(handle); + let _ = app.emit( + "remote-input:running", + serde_json::json!({"running": true, "port": port, "urls": urls, "pin": pin}), + ); + log::info!("[remote-input] server started on port {port}"); + } + Err(e) => { + let _ = app.emit( + "remote-input:error", + serde_json::json!({"reason": e, "port": port}), + ); + log::error!("[remote-input] server start failed: {e}"); + } + } + }); + } + /// 返回当前听写阶段(read-only 快照),供 CLI 入口在 dispatch toggle 时决策。 /// 与原热键边沿走的 `handle_pressed` 分支完全相同的判定逻辑:Idle → start, /// Listening → stop。可用于桌面快捷键 → CLI 转发的备用触发路径。 diff --git a/openless-all/app/src-tauri/src/coordinator/dictation.rs b/openless-all/app/src-tauri/src/coordinator/dictation.rs index dfbbe3a5..b81c1590 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation.rs @@ -585,20 +585,27 @@ pub(super) async fn begin_session(inner: &Arc) -> Result<(), String> { let active_asr = CredentialsVault::get_active_asr(); - if let Err(message) = ensure_microphone_permission(inner) { - log::warn!("[coord] microphone permission gate failed: {message}"); - emit_capsule( - inner, - CapsuleState::Error, - 0.0, - 0, - Some(message.clone()), - None, - ); - restore_prepared_windows_ime_session(inner, current_session_id); - inner.state.lock().phase = SessionPhase::Idle; - schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); - return Err(message); + // 远程输入的音频来自手机,电脑不开本地麦克风,跳过电脑麦克风权限闸门 + // (否则电脑麦克风为 Denied 时会把远程会话也挡住)。 + if !inner + .remote_source_active + .load(std::sync::atomic::Ordering::Relaxed) + { + if let Err(message) = ensure_microphone_permission(inner) { + log::warn!("[coord] microphone permission gate failed: {message}"); + emit_capsule( + inner, + CapsuleState::Error, + 0.0, + 0, + Some(message.clone()), + None, + ); + restore_prepared_windows_ime_session(inner, current_session_id); + inner.state.lock().phase = SessionPhase::Idle; + schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); + return Err(message); + } } // 不在这里 emit Recording capsule —— 让 start_recorder_for_starting 在 @@ -916,6 +923,22 @@ pub(super) async fn start_recorder_for_starting( active_asr: &str, consumer: Arc, ) -> Result<(), String> { + // 远程输入:不开本地 cpal,把组装好的 consumer 交给 WS server 喂手机 PCM。 + // 其余(Starting→Listening、pending_stop、cancel race、end_session 收尾)与本地 + // 听写完全一致。详见 Coordinator::start_remote_dictation。 + if inner + .remote_source_active + .load(std::sync::atomic::Ordering::Relaxed) + { + *inner.remote_audio_sink.lock() = Some(Arc::clone(&consumer)); + inner + .audio_archive_active + .store(false, std::sync::atomic::Ordering::Relaxed); + emit_capsule(inner, CapsuleState::Recording, 0.0, 0, None, None); + log::info!("[coord] remote audio source active (asr={active_asr}, session={session_id})"); + return Ok(()); + } + let inner_for_level = Arc::clone(inner); // 节流:电平回调本身约 185 Hz(cpal 默认音频块),全部转发到前端会让 CSS // transition 互相覆盖、视觉上"被平均"成静止。限制为 ~30 Hz(33ms 最少间隔), diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 4b65b3d8..90e96cd0 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -31,6 +31,7 @@ mod persistence; mod polish; mod qa_hotkey; mod recorder; +mod remote_server; mod selection; mod shortcut_binding; mod types; @@ -316,6 +317,8 @@ pub fn run() { let app_handle = app.handle().clone(); coordinator.bind_app(app_handle); coordinator.start_hotkey_listener(); + // 远程输入:按 prefs 启动局域网录音服务(未启用时为 no-op)。 + coordinator.refresh_remote_server(); // QA / custom combo hotkeys use `global-hotkey` (Carbon on macOS). // Start those after RunEvent::Ready, when the AppKit event loop is live. if std::env::var("OPENLESS_SHOW_MAIN_ON_START").ok().as_deref() == Some("1") { @@ -336,6 +339,9 @@ pub fn run() { commands::get_settings, commands::get_default_style_system_prompts, commands::set_settings, + commands::get_remote_input_status, + commands::list_local_ips, + commands::regenerate_remote_pin, commands::get_update_channel, commands::set_update_channel, commands::fetch_latest_beta_release, diff --git a/openless-all/app/src-tauri/src/remote_server/assets/app.js b/openless-all/app/src-tauri/src/remote_server/assets/app.js new file mode 100644 index 00000000..1b47fb96 --- /dev/null +++ b/openless-all/app/src-tauri/src/remote_server/assets/app.js @@ -0,0 +1,718 @@ +/* ============================================================ + * OpenLess 远程输入 — 手机端录音页 + * 纯静态,无外部依赖。通过 WSS 把 16kHz/单声道/16bit LE PCM + * 实时推送给 PC 端 Rust 服务。 + * ========================================================== */ +(function () { + 'use strict'; + + // ---------- 常量 ---------- + var TARGET_SR = 16000; // 目标采样率,必须与 PC 端一致 + var MODE_KEY = 'ol_remote_mode'; // localStorage 键 + + // ---------- DOM ---------- + var $ = function (id) { return document.getElementById(id); }; + var screenPin = $('screen-pin'); + var screenRec = $('screen-rec'); + var screenOffline = $('screen-offline'); + + var pinInput = $('pin-input'); + var pinError = $('pin-error'); + var btnConnect = $('btn-connect'); + + var recordBtn = $('btn-record'); + var recordLabel = $('record-label'); + var statusBar = $('status-bar'); + var statusText = $('status-text'); + var levelBar = $('level-bar'); + var recTip = $('rec-tip'); + var connDot = $('conn-dot'); + var modeSwitch = $('mode-switch'); + + var btnReconnect = $('btn-reconnect'); + var offlineReason = $('offline-reason'); + + // ---------- 状态 ---------- + var ws = null; + var authed = false; + var recording = false; // 是否正在录音(决定是否 send 音频) + var busy = false; // PC 端忙,本次禁用 + var mode = readMode(); // 'toggle' | 'hold' + var lastPin = ''; + + // 音频相关 + var audioCtx = null; + var mediaStream = null; + var sourceNode = null; + var workletNode = null; + var scriptNode = null; + var workletUrl = null; + var usingWorklet = false; + // ScriptProcessor 兜底用的重采样状态(跨块保留) + var resampleState = { phase: 0, last: 0, hasLast: false }; + + // ============================================================ + // 屏幕切换 + // ============================================================ + function showScreen(which) { + screenPin.classList.toggle('active', which === 'pin'); + screenRec.classList.toggle('active', which === 'rec'); + screenOffline.classList.toggle('active', which === 'offline'); + } + + // ============================================================ + // 模式(toggle / hold) + // ============================================================ + function readMode() { + var m = null; + try { m = localStorage.getItem(MODE_KEY); } catch (e) {} + return m === 'hold' ? 'hold' : 'toggle'; + } + function writeMode(m) { + mode = m; + try { localStorage.setItem(MODE_KEY, m); } catch (e) {} + syncModeUI(); + } + function syncModeUI() { + var btns = modeSwitch.querySelectorAll('.mode-btn'); + for (var i = 0; i < btns.length; i++) { + btns[i].classList.toggle('active', btns[i].getAttribute('data-mode') === mode); + } + if (mode === 'hold') { + recTip.textContent = '按住大按钮说话,松开结束并识别。'; + recordLabel.textContent = recording ? '松开结束' : '按住说话'; + recordBtn.style.touchAction = 'none'; // hold 防滚动 + } else { + recTip.textContent = '点击大按钮开始录音,再次点击结束并识别。'; + recordLabel.textContent = recording ? '点击结束' : '点击开始'; + recordBtn.style.touchAction = 'manipulation'; + } + } + + // 切换模式时若约定的 prefer 变化,告知 PC(若已连接) + modeSwitch.addEventListener('click', function (e) { + var t = e.target.closest('.mode-btn'); + if (!t) return; + var m = t.getAttribute('data-mode'); + if (m === mode) return; + // 录音中切换模式先安全停止(取消本次,避免状态错乱) + if (recording) cancelRecording(); + writeMode(m); + }); + + // ============================================================ + // 状态文字 / 音量 + // ============================================================ + function setStatus(text, kind) { + statusText.textContent = text; + statusBar.classList.remove('is-error', 'is-ok', 'is-work'); + if (kind === 'error') statusBar.classList.add('is-error'); + else if (kind === 'ok') statusBar.classList.add('is-ok'); + else if (kind === 'work') statusBar.classList.add('is-work'); + } + function setLevel(v) { + if (typeof v !== 'number' || isNaN(v)) return; + v = Math.max(0, Math.min(1, v)); + levelBar.style.width = (v * 100).toFixed(1) + '%'; + } + + // ============================================================ + // WebSocket + // ============================================================ + function wsSendJSON(obj) { + if (ws && ws.readyState === 1) { + try { ws.send(JSON.stringify(obj)); } catch (e) {} + } + } + + function connect(pin) { + lastPin = pin; + closeWS(); // 清理旧连接 + authed = false; + busy = false; + + var url = 'wss://' + location.host + '/ws'; + try { + ws = new WebSocket(url); + } catch (e) { + showPinError('无法建立连接,请检查网络。'); + resetConnectBtn(); + return; + } + ws.binaryType = 'arraybuffer'; + + ws.onopen = function () { + // 连上立即握手 + wsSendJSON({ type: 'hello', pin: pin, prefer: mode }); + }; + + ws.onmessage = function (ev) { + if (typeof ev.data !== 'string') return; // 下行只处理文本 + var msg; + try { msg = JSON.parse(ev.data); } catch (e) { return; } + handleMessage(msg); + }; + + ws.onerror = function () { + // onerror 后通常紧跟 onclose,统一在 close 里处理 UI + }; + + ws.onclose = function () { + var wasAuthed = authed; + authed = false; + recording = false; + teardownAudio(); + if (wasAuthed) { + // 已进入录音屏后断开 → 断线屏 + offlineReason.textContent = '与电脑的连接已中断。'; + showScreen('offline'); + } else if (!isPinScreen()) { + // 连接过程中失败 + showScreen('pin'); + showPinError('连接失败,请确认电脑端服务正在运行。'); + } + resetConnectBtn(); + }; + } + + function closeWS() { + if (ws) { + ws.onopen = ws.onmessage = ws.onerror = ws.onclose = null; + try { ws.close(); } catch (e) {} + ws = null; + } + } + + function handleMessage(msg) { + if (!msg || typeof msg.type !== 'string') return; + + switch (msg.type) { + case 'auth': + if (msg.ok) { + authed = true; + busy = false; + enterRecScreen(); + } else { + authed = false; + var reason = msg.reason === 'locked' + ? '配对已锁定,请在电脑上重新生成配对码。' + : '配对码错误,请重试。'; + closeWS(); + showScreen('pin'); + showPinError(reason); + resetConnectBtn(); + } + break; + + case 'status': + applyStatusKind(msg); + break; + + case 'level': + setLevel(msg.value); + break; + + case 'busy': + busy = true; + recording = false; + teardownAudioCapture(); // 停止采集但保留 ctx + updateRecordBtnUI(); + setStatus('电脑忙:' + (msg.reason || '请稍候'), 'error'); + // 短暂后解除忙态,允许重试 + setTimeout(function () { + busy = false; + updateRecordBtnUI(); + if (!recording) setStatus('准备就绪', null); + }, 1500); + break; + } + } + + function applyStatusKind(msg) { + switch (msg.kind) { + case 'recording': + setStatus('🎤 录音中', 'work'); + break; + case 'transcribing': + setStatus('🔄 识别中', 'work'); + break; + case 'polishing': + setStatus('✨ 润色中', 'work'); + break; + case 'done': + var n = (typeof msg.insertedChars === 'number') ? msg.insertedChars : 0; + setStatus('✅ 已输入 ' + n + ' 字', 'ok'); + setLevel(0); + break; + case 'error': + setStatus('❌ ' + (msg.message || '发生错误'), 'error'); + setLevel(0); + break; + default: + if (msg.message) setStatus(msg.message, null); + } + } + + // ============================================================ + // 屏幕状态判断辅助 + // ============================================================ + function isPinScreen() { return screenPin.classList.contains('active'); } + + function enterRecScreen() { + showPinError(''); + showScreen('rec'); + connDot.style.background = 'var(--ok)'; + syncModeUI(); + updateRecordBtnUI(); + setStatus('准备就绪', null); + setLevel(0); + } + + // ============================================================ + // PIN 屏交互 + // ============================================================ + pinInput.addEventListener('input', function () { + // 仅保留数字 + var v = pinInput.value.replace(/\D+/g, '').slice(0, 6); + if (v !== pinInput.value) pinInput.value = v; + showPinError(''); + }); + pinInput.addEventListener('keydown', function (e) { + if (e.key === 'Enter') doConnect(); + }); + btnConnect.addEventListener('click', doConnect); + + function doConnect() { + var pin = (pinInput.value || '').replace(/\D+/g, ''); + if (pin.length !== 6) { + showPinError('请输入 6 位数字配对码。'); + return; + } + showPinError(''); + btnConnect.disabled = true; + btnConnect.textContent = '连接中…'; + connect(pin); + } + + function showPinError(text) { + if (!text) { + pinError.hidden = true; + pinError.textContent = ''; + } else { + pinError.hidden = false; + pinError.textContent = text; + } + } + function resetConnectBtn() { + btnConnect.disabled = false; + btnConnect.textContent = '连接'; + } + + // 重新连接 + btnReconnect.addEventListener('click', function () { + showScreen('pin'); + showPinError(''); + resetConnectBtn(); + if (lastPin) { + pinInput.value = lastPin; + } + }); + + // ============================================================ + // 录音按钮交互(toggle / hold) + // ============================================================ + function updateRecordBtnUI() { + recordBtn.classList.toggle('recording', recording); + recordBtn.classList.toggle('busy', busy && !recording); + if (recording) { + recordLabel.textContent = (mode === 'hold') ? '松开结束' : '点击结束'; + } else { + recordLabel.textContent = (mode === 'hold') ? '按住说话' : '点击开始'; + } + } + + // toggle 模式:click 切换 + recordBtn.addEventListener('click', function () { + if (mode !== 'toggle') return; + if (!authed || busy) return; + if (recording) stopRecording(); + else startRecording(); + }); + + // hold 模式:按下/抬起/取消 + recordBtn.addEventListener('pointerdown', function (e) { + if (mode !== 'hold') return; + if (!authed || busy) return; + e.preventDefault(); + try { recordBtn.setPointerCapture(e.pointerId); } catch (err) {} + if (!recording) startRecording(); + }); + recordBtn.addEventListener('pointerup', function (e) { + if (mode !== 'hold') return; + e.preventDefault(); + if (recording) stopRecording(); + }); + recordBtn.addEventListener('pointercancel', function () { + if (mode !== 'hold') return; + if (recording) cancelRecording(); // 来电/切后台 → 丢弃 + }); + // hold 时指针滑出按钮也按抬起处理(防止卡在录音态) + recordBtn.addEventListener('lostpointercapture', function () { + if (mode !== 'hold') return; + if (recording) stopRecording(); + }); + + // ============================================================ + // 录音流程 + // ============================================================ + function startRecording() { + if (recording) return; + if (!ws || ws.readyState !== 1) { + setStatus('连接已断开', 'error'); + return; + } + // 先乐观置态,保证 iOS 在手势同步栈内 resume() + recording = true; + updateRecordBtnUI(); + setStatus('正在准备麦克风…', 'work'); + + ensureAudio() + .then(function () { + if (!recording) { + // 期间已被取消/松手 + teardownAudioCapture(); + return; + } + wsSendJSON({ type: 'start' }); + setStatus('🎤 录音中', 'work'); + }) + .catch(function (err) { + recording = false; + updateRecordBtnUI(); + setStatus(micErrorText(err), 'error'); + }); + } + + function stopRecording() { + if (!recording) return; + recording = false; + updateRecordBtnUI(); + teardownAudioCapture(); + wsSendJSON({ type: 'stop' }); + setStatus('🔄 识别中', 'work'); + setLevel(0); + } + + function cancelRecording() { + if (!recording) { + // 即便未在录音也确保采集停掉 + teardownAudioCapture(); + return; + } + recording = false; + updateRecordBtnUI(); + teardownAudioCapture(); + wsSendJSON({ type: 'cancel' }); + setStatus('已取消', null); + setLevel(0); + } + + function micErrorText(err) { + var name = err && err.name ? err.name : ''; + if (name === 'NotAllowedError' || name === 'SecurityError') { + return '❌ 麦克风权限被拒绝,请在浏览器设置中允许。'; + } + if (name === 'NotFoundError' || name === 'OverconstrainedError') { + return '❌ 未找到可用麦克风。'; + } + if (name === 'NotReadableError') { + return '❌ 麦克风被其他应用占用。'; + } + return '❌ 无法启动录音' + (name ? '(' + name + ')' : '') + '。'; + } + + // ============================================================ + // 音频:获取设备 + 建立采集链 + // ============================================================ + // 确保 AudioContext / getUserMedia / 采集节点就绪并开始推流。 + // 必须在用户手势调用栈内(startRecording 由手势触发)。 + function ensureAudio() { + // 不支持 getUserMedia + if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { + return Promise.reject(new Error('UNSUPPORTED:浏览器不支持录音,请升级或换浏览器')); + } + + // 1) AudioContext(iOS 需手势内 resume) + if (!audioCtx) { + var AC = window.AudioContext || window.webkitAudioContext; + if (!AC) { + return Promise.reject(new Error('UNSUPPORTED:浏览器不支持录音,请升级或换浏览器')); + } + audioCtx = new AC(); + } + + var resumeP = (audioCtx.state === 'suspended') + ? audioCtx.resume().catch(function () {}) + : Promise.resolve(); + + return resumeP + .then(function () { + // 2) 麦克风流(已存在则复用) + if (mediaStream) return mediaStream; + return navigator.mediaDevices.getUserMedia({ + audio: { + channelCount: 1, + echoCancellation: true, + noiseSuppression: true, + autoGainControl: true + }, + video: false + }).then(function (stream) { + mediaStream = stream; + return stream; + }); + }) + .then(function (stream) { + // 3) 建立采集图(若已建好则跳过) + if (sourceNode) return; + sourceNode = audioCtx.createMediaStreamSource(stream); + return buildCaptureGraph(); + }); + } + + // 建立 AudioWorklet(优先)或 ScriptProcessor(兜底) + function buildCaptureGraph() { + var inSr = audioCtx.sampleRate || 48000; + + // 优先 AudioWorklet + if (audioCtx.audioWorklet && typeof AudioWorkletNode !== 'undefined') { + return loadWorklet() + .then(function () { + workletNode = new AudioWorkletNode(audioCtx, 'ol-pcm-worklet', { + numberOfInputs: 1, + numberOfOutputs: 0, + channelCount: 1, + processorOptions: { inSr: inSr, targetSr: TARGET_SR } + }); + workletNode.port.onmessage = function (e) { + // e.data 是已转换好的 Int16 LE ArrayBuffer + sendAudio(e.data); + }; + sourceNode.connect(workletNode); + usingWorklet = true; + }) + .catch(function () { + // worklet 加载失败 → 回退 ScriptProcessor + usingWorklet = false; + buildScriptProcessor(inSr); + }); + } + + // 无 audioWorklet:直接兜底 + usingWorklet = false; + buildScriptProcessor(inSr); + return Promise.resolve(); + } + + // ---- AudioWorklet processor(字符串 → Blob URL 加载) ---- + function loadWorklet() { + if (workletUrl) return audioCtx.audioWorklet.addModule(workletUrl); + + var code = + 'class OlPcmWorklet extends AudioWorkletProcessor {' + + ' constructor(o){' + + ' super();' + + ' var p=(o&&o.processorOptions)||{};' + + ' this.inSr=p.inSr||sampleRate;' + + ' this.targetSr=p.targetSr||16000;' + + ' this.ratio=this.inSr/this.targetSr;' + + ' this.phase=0;' + // 当前小数相位 + ' this.last=0;' + // 上一块最后一个样本(用于跨块拼接) + ' this.hasLast=false;' + + ' }' + + ' process(inputs){' + + ' var ch=inputs[0]&&inputs[0][0];' + + ' if(!ch||ch.length===0){return true;}' + + ' var ratio=this.ratio;' + + ' var phase=this.phase;' + + ' var prev=this.last;' + + ' var hasPrev=this.hasLast;' + + ' var n=ch.length;' + + // 估算输出样本数上界 + ' var outCap=Math.ceil((n+1)/ratio)+2;' + + ' var pcm=new ArrayBuffer(outCap*2);' + + ' var dv=new DataView(pcm);' + + ' var oi=0;' + + // 线性插值:phase 以"输入样本"为单位推进,step=inSr/16000 + // i=floor(phase),frac=phase-i;a=样本[i],b=样本[i+1] + // 跨块时 i 可能为 -1,用 prev 作为 a。 + ' while(true){' + + ' var i=Math.floor(phase);' + + ' var frac=phase-i;' + + ' var a,b;' + + ' if(i+1>=n){break;}' + // 需要 i 和 i+1 都在块内(或 a 用 prev) + ' if(i<0){' + + ' if(!hasPrev){phase+=ratio;continue;}' + + ' a=prev;b=ch[0];' + + ' }else{' + + ' a=ch[i];b=ch[i+1];' + + ' }' + + ' var s=a+(b-a)*frac;' + + ' if(s>1)s=1;else if(s<-1)s=-1;' + + ' dv.setInt16(oi*2, (s*32767)|0, true);' + + ' oi++;' + + ' phase+=ratio;' + + ' }' + + // 保留余数:把 phase 拉回到相对下一块起点 + ' this.phase=phase-n;' + + ' this.last=ch[n-1];' + + ' this.hasLast=true;' + + ' if(oi>0){' + + ' var out=pcm.slice(0,oi*2);' + + ' this.port.postMessage(out,[out]);' + + ' }' + + ' return true;' + + ' }' + + '}' + + 'registerProcessor("ol-pcm-worklet", OlPcmWorklet);'; + + workletUrl = URL.createObjectURL(new Blob([code], { type: 'application/javascript' })); + return audioCtx.audioWorklet.addModule(workletUrl); + } + + // ---- ScriptProcessor 兜底 ---- + function buildScriptProcessor(inSr) { + scriptNode = audioCtx.createScriptProcessor(4096, 1, 1); + resampleState.phase = 0; + resampleState.last = 0; + resampleState.hasLast = false; + + scriptNode.onaudioprocess = function (e) { + if (!recording) return; + var input = e.inputBuffer.getChannelData(0); + var buf = resampleToInt16LE(input, inSr); + if (buf && buf.byteLength) sendAudio(buf); + }; + // ScriptProcessor 需连到 destination 才会触发(用静音增益避免回放) + sourceNode.connect(scriptNode); + var silent = audioCtx.createGain(); + silent.gain.value = 0; + scriptNode.connect(silent); + silent.connect(audioCtx.destination); + scriptNode._silentGain = silent; + } + + // 主线程线性插值重采样(给 ScriptProcessor 用),逻辑与 worklet 一致 + function resampleToInt16LE(ch, inSr) { + var ratio = inSr / TARGET_SR; + var phase = resampleState.phase; + var prev = resampleState.last; + var hasPrev = resampleState.hasLast; + var n = ch.length; + if (n === 0) return null; + + var outCap = Math.ceil((n + 1) / ratio) + 2; + var pcm = new ArrayBuffer(outCap * 2); + var dv = new DataView(pcm); + var oi = 0; + + while (true) { + var i = Math.floor(phase); + var frac = phase - i; + var a, b; + if (i + 1 >= n) break; + if (i < 0) { + if (!hasPrev) { phase += ratio; continue; } + a = prev; b = ch[0]; + } else { + a = ch[i]; b = ch[i + 1]; + } + var s = a + (b - a) * frac; + if (s > 1) s = 1; else if (s < -1) s = -1; + dv.setInt16(oi * 2, (s * 32767) | 0, true); + oi++; + phase += ratio; + } + + resampleState.phase = phase - n; + resampleState.last = ch[n - 1]; + resampleState.hasLast = true; + + return oi > 0 ? pcm.slice(0, oi * 2) : null; + } + + // 发送二进制音频帧(仅录音中且连接可用) + function sendAudio(buf) { + if (!recording) return; + if (ws && ws.readyState === 1 && buf && buf.byteLength) { + try { ws.send(buf); } catch (e) {} + } + } + + // ============================================================ + // 音频清理 + // ============================================================ + // 仅停止"采集/推流"(断开节点),保留 audioCtx & mediaStream 以便快速重启。 + function teardownAudioCapture() { + try { if (workletNode) { workletNode.port.onmessage = null; workletNode.disconnect(); } } catch (e) {} + workletNode = null; + + try { + if (scriptNode) { + scriptNode.onaudioprocess = null; + scriptNode.disconnect(); + if (scriptNode._silentGain) { + try { scriptNode._silentGain.disconnect(); } catch (e2) {} + } + } + } catch (e) {} + scriptNode = null; + + try { if (sourceNode) sourceNode.disconnect(); } catch (e) {} + // sourceNode 置空,下次 ensureAudio 重新从 stream 创建 + sourceNode = null; + + // 复位兜底重采样状态 + resampleState.phase = 0; + resampleState.last = 0; + resampleState.hasLast = false; + } + + // 彻底释放(断线时):停止麦克风轨道并关闭 ctx。 + function teardownAudio() { + teardownAudioCapture(); + if (mediaStream) { + try { + var tracks = mediaStream.getTracks(); + for (var i = 0; i < tracks.length; i++) tracks[i].stop(); + } catch (e) {} + mediaStream = null; + } + // 不强行 close ctx(部分浏览器再次 new 较慢);仅在确实需要时挂起 + if (audioCtx && audioCtx.state === 'running') { + try { audioCtx.suspend(); } catch (e) {} + } + } + + // ============================================================ + // 页面可见性:切后台时若在 hold 录音则取消,避免半截音频 + // ============================================================ + document.addEventListener('visibilitychange', function () { + if (document.hidden && recording) { + cancelRecording(); + } + }); + + // ============================================================ + // 初始化 + // ============================================================ + function init() { + syncModeUI(); + showScreen('pin'); + showPinError(''); + // 自动聚焦 PIN(部分移动端会被策略拦截,忽略失败) + setTimeout(function () { try { pinInput.focus(); } catch (e) {} }, 200); + } + + init(); +})(); diff --git a/openless-all/app/src-tauri/src/remote_server/assets/index.html b/openless-all/app/src-tauri/src/remote_server/assets/index.html new file mode 100644 index 00000000..d4eb4a56 --- /dev/null +++ b/openless-all/app/src-tauri/src/remote_server/assets/index.html @@ -0,0 +1,87 @@ + + + + + + + + OpenLess 远程输入 + + + +
+ +
+
+ +

OpenLess 远程输入

+

在手机上录音,实时输入到电脑

+
+ +
+ + + + +
+
+ + +
+
+ + OpenLess +
+ + +
+
+ +
+ + + + +
+ 准备就绪 +
+
+ +

点击大按钮开始录音,再次点击结束并识别。

+
+ + +
+
+
📵
+

连接已断开

+

与电脑的连接已中断。

+ +
+
+
+ + +
+ 首次访问浏览器会提示“连接不安全”(本地自签名证书)。 + Android Chrome:点“高级”→“继续前往”; + iOS Safari:点“显示详情”→“访问此网站”。 +
+ + + + diff --git a/openless-all/app/src-tauri/src/remote_server/assets/style.css b/openless-all/app/src-tauri/src/remote_server/assets/style.css new file mode 100644 index 00000000..d80b9c7d --- /dev/null +++ b/openless-all/app/src-tauri/src/remote_server/assets/style.css @@ -0,0 +1,367 @@ +/* ===== OpenLess 远程输入 — 移动端深色样式 ===== */ + +:root { + --bg: #0f1115; + --bg-soft: #171a21; + --card: #1c2029; + --card-border: #2a2f3a; + --text: #e7eaf0; + --text-dim: #9aa3b2; + --text-faint: #6b7280; + --accent: #4f8cff; + --accent-strong: #3b78f0; + --danger: #ff5470; + --danger-soft: #e0455f; + --ok: #34d399; + --warn: #fbbf24; + --shadow: 0 10px 30px rgba(0, 0, 0, .45); + --safe-bottom: env(safe-area-inset-bottom, 0px); +} + +* { + box-sizing: border-box; + -webkit-tap-highlight-color: transparent; +} + +html, body { + margin: 0; + padding: 0; + height: 100%; +} + +body { + background: + radial-gradient(120% 80% at 50% -10%, #1a2030 0%, var(--bg) 60%) fixed, + var(--bg); + color: var(--text); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", + "Hiragino Sans GB", "Microsoft YaHei", Roboto, Helvetica, Arial, sans-serif; + font-size: 16px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; + user-select: none; + -webkit-user-select: none; + overscroll-behavior: none; +} + +#app { + min-height: 100%; + display: flex; + flex-direction: column; + /* 给底部证书提示留出空间 */ + padding-bottom: calc(96px + var(--safe-bottom)); +} + +/* ===== 屏幕切换 ===== */ +.screen { + display: none; + flex: 1; + flex-direction: column; + padding: 24px 20px; + animation: fadeIn .25s ease; +} +.screen.active { + display: flex; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(6px); } + to { opacity: 1; transform: translateY(0); } +} + +/* ===== 品牌头 ===== */ +.brand { + text-align: center; + margin: 28px 0 22px; +} +.brand-logo { + font-size: 56px; + line-height: 1; +} +.brand-title { + font-size: 22px; + font-weight: 700; + margin: 14px 0 4px; + letter-spacing: .3px; +} +.brand-sub { + margin: 0; + color: var(--text-dim); + font-size: 14px; +} + +/* ===== 卡片 ===== */ +.card { + background: var(--card); + border: 1px solid var(--card-border); + border-radius: 18px; + padding: 22px 20px; + box-shadow: var(--shadow); +} +.card-center { + text-align: center; +} + +.field-label { + display: block; + font-size: 13px; + color: var(--text-dim); + margin-bottom: 10px; +} + +/* ===== PIN 输入 ===== */ +.pin-input { + width: 100%; + font-size: 30px; + letter-spacing: 14px; + text-align: center; + padding: 16px 12px; + color: var(--text); + background: var(--bg-soft); + border: 2px solid var(--card-border); + border-radius: 14px; + outline: none; + font-variant-numeric: tabular-nums; + transition: border-color .15s ease; +} +.pin-input::placeholder { + color: var(--text-faint); + letter-spacing: 14px; +} +.pin-input:focus { + border-color: var(--accent); +} + +/* ===== 按钮 ===== */ +.btn { + -webkit-appearance: none; + appearance: none; + display: block; + width: 100%; + margin-top: 16px; + padding: 15px 18px; + font-size: 17px; + font-weight: 600; + color: #fff; + border: none; + border-radius: 14px; + cursor: pointer; + transition: transform .08s ease, background .15s ease, opacity .15s ease; +} +.btn:active { transform: scale(.98); } +.btn:disabled { opacity: .55; cursor: default; } + +.btn-primary { + background: linear-gradient(180deg, var(--accent) 0%, var(--accent-strong) 100%); + box-shadow: 0 6px 18px rgba(59, 120, 240, .35); +} + +.hint-error { + color: var(--danger); + font-size: 13px; + margin: 12px 2px 0; + min-height: 1em; +} + +/* ===== 录音屏头部 ===== */ +.rec-header { + display: flex; + align-items: center; + gap: 10px; + padding-bottom: 8px; +} +.conn-dot { + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--ok); + box-shadow: 0 0 0 3px rgba(52, 211, 153, .18); + flex: none; +} +.rec-header-title { + font-weight: 700; + font-size: 16px; + letter-spacing: .4px; +} +.mode-switch { + margin-left: auto; + display: inline-flex; + background: var(--bg-soft); + border: 1px solid var(--card-border); + border-radius: 12px; + padding: 3px; + gap: 2px; +} +.mode-btn { + -webkit-appearance: none; + appearance: none; + border: none; + background: transparent; + color: var(--text-dim); + font-size: 13px; + font-weight: 600; + padding: 7px 14px; + border-radius: 9px; + cursor: pointer; + transition: background .15s ease, color .15s ease; +} +.mode-btn.active { + background: var(--accent); + color: #fff; +} + +/* ===== 录音主区 ===== */ +.rec-main { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 26px; +} + +/* 录音大按钮 */ +.record-btn { + position: relative; + width: 168px; + height: 168px; + border-radius: 50%; + border: none; + cursor: pointer; + color: #fff; + background: radial-gradient(circle at 50% 38%, #2a3550 0%, #1b2136 100%); + box-shadow: + 0 12px 34px rgba(0, 0, 0, .5), + inset 0 0 0 2px rgba(255, 255, 255, .04); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 6px; + transition: transform .1s ease, box-shadow .2s ease, background .2s ease; + user-select: none; + -webkit-user-select: none; +} +.record-btn:active { transform: scale(.97); } + +.record-btn-ring { + position: absolute; + inset: -6px; + border-radius: 50%; + border: 2px solid rgba(79, 140, 255, .35); + opacity: 0; + pointer-events: none; +} +.record-btn-icon { + font-size: 50px; + line-height: 1; + transition: transform .15s ease; +} +.record-btn-label { + font-size: 14px; + font-weight: 600; + color: var(--text-dim); + letter-spacing: .5px; +} + +/* 录音中:红色 + 呼吸脉冲动画 */ +.record-btn.recording { + background: radial-gradient(circle at 50% 38%, #ff6a82 0%, #d63a55 100%); + box-shadow: 0 12px 34px rgba(214, 58, 85, .5); + animation: breathe 1.6s ease-in-out infinite; +} +.record-btn.recording .record-btn-label { color: #fff; } +.record-btn.recording .record-btn-ring { + opacity: 1; + animation: pulseRing 1.6s ease-out infinite; +} + +@keyframes breathe { + 0%, 100% { transform: scale(1); } + 50% { transform: scale(1.04); } +} +@keyframes pulseRing { + 0% { transform: scale(1); opacity: .7; } + 70% { transform: scale(1.28); opacity: 0; } + 100% { transform: scale(1.28); opacity: 0; } +} + +/* 忙/禁用态 */ +.record-btn.busy { + opacity: .6; + cursor: default; + animation: none; +} + +/* ===== 音量条 ===== */ +.level-wrap { + width: 78%; + max-width: 320px; + height: 8px; + border-radius: 99px; + background: var(--bg-soft); + border: 1px solid var(--card-border); + overflow: hidden; +} +.level-bar { + height: 100%; + width: 0%; + border-radius: 99px; + background: linear-gradient(90deg, var(--ok), var(--accent)); + transition: width .08s linear; +} + +/* ===== 状态条 ===== */ +.status-bar { + min-height: 28px; + padding: 8px 18px; + border-radius: 99px; + background: var(--bg-soft); + border: 1px solid var(--card-border); + font-size: 15px; + font-weight: 600; + color: var(--text); + text-align: center; + max-width: 90%; +} +.status-bar.is-error { color: var(--danger); border-color: rgba(255, 84, 112, .4); } +.status-bar.is-ok { color: var(--ok); border-color: rgba(52, 211, 153, .4); } +.status-bar.is-work { color: var(--accent); } + +/* ===== 提示文字 ===== */ +.rec-tip { + text-align: center; + color: var(--text-faint); + font-size: 13px; + margin: 18px 0 0; +} + +/* ===== 断线屏 ===== */ +.offline-icon { font-size: 48px; } +.offline-title { font-size: 20px; margin: 12px 0 6px; } +.offline-sub { color: var(--text-dim); font-size: 14px; margin: 0 0 8px; } + +/* ===== 底部证书提示(固定) ===== */ +.cert-tip { + position: fixed; + left: 0; + right: 0; + bottom: 0; + padding: 12px 16px calc(12px + var(--safe-bottom)); + font-size: 12px; + line-height: 1.5; + color: var(--text-faint); + background: rgba(15, 17, 21, .92); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); + border-top: 1px solid var(--card-border); + text-align: center; +} + +/* 小屏微调 */ +@media (max-height: 640px) { + .brand { margin: 14px 0; } + .brand-logo { font-size: 44px; } + .record-btn { width: 148px; height: 148px; } + .record-btn-icon { font-size: 44px; } +} diff --git a/openless-all/app/src-tauri/src/remote_server/mod.rs b/openless-all/app/src-tauri/src/remote_server/mod.rs new file mode 100644 index 00000000..f286c3d2 --- /dev/null +++ b/openless-all/app/src-tauri/src/remote_server/mod.rs @@ -0,0 +1,439 @@ +//! 远程输入(局域网手机录音)的 HTTPS + WebSocket 服务。 +//! +//! 手机在同一局域网用浏览器打开 `https://:`,得到一个录音页 +//! (assets/ 下的 index.html / app.js / style.css,编译期 include_str! 内嵌)。 +//! 手机录音以 16k/单声道/16-bit LE PCM 经 WebSocket 实时推回 PC,由 Coordinator +//! 当作"手机麦克风"喂进现有「录音→ASR→润色→光标落字」管线(见 +//! `Coordinator::start_remote_dictation`)。 +//! +//! 关键约束:浏览器 `getUserMedia` 仅在安全上下文可用,所以必须 HTTPS。证书用 +//! rcgen 自签名(SAN 含本机局域网 IP),手机首次访问需手动信任。TLS 走 ring +//! 后端(与项目 reqwest/tungstenite 一致,避免 aws-lc-sys 的 C 编译依赖)。 + +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use axum::{ + extract::ws::{Message, WebSocket, WebSocketUpgrade}, + extract::State, + response::{Html, IntoResponse}, + routing::get, + Router, +}; +use hyper_util::rt::{TokioExecutor, TokioIo}; +use parking_lot::Mutex; +use serde::Serialize; +use tauri::{AppHandle, Listener}; +use tokio::net::TcpListener; +use tokio_rustls::TlsAcceptor; + +use crate::coordinator::Coordinator; + +mod assets { + pub const INDEX_HTML: &str = include_str!("assets/index.html"); + pub const APP_JS: &str = include_str!("assets/app.js"); + pub const STYLE_CSS: &str = include_str!("assets/style.css"); +} + +const HEADER_HTML: &str = "text/html; charset=utf-8"; +const HEADER_JS: &str = "application/javascript; charset=utf-8"; +const HEADER_CSS: &str = "text/css; charset=utf-8"; + +/// 同一来源连续输错 PIN 的锁定阈值与时长。 +const PIN_MAX_FAILS: u32 = 5; +const PIN_LOCK_SECS: u64 = 60; + +// ───────────────────────── 对外类型 ───────────────────────── + +pub struct RemoteServerConfig { + pub port: u16, + pub pin: String, + pub coordinator: Arc, + pub app: AppHandle, +} + +/// 运行中的服务句柄。drop / shutdown 触发优雅关停。 +pub struct RemoteServerHandle { + shutdown_tx: Option>, + join: tauri::async_runtime::JoinHandle<()>, + pub bound_port: u16, + #[allow(dead_code)] + pub pin: String, +} + +impl RemoteServerHandle { + /// 通知 accept loop 退出并等待其结束。 + pub async fn shutdown(mut self) { + if let Some(tx) = self.shutdown_tx.take() { + let _ = tx.send(()); + } + let _ = self.join.await; + } +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RemoteInputStatus { + pub running: bool, + pub port: u16, + pub pin: String, + pub urls: Vec, +} + +// ───────────────────────── 工具函数 ───────────────────────── + +/// 生成 6 位数字配对码。用 uuid v4 的随机字节取模,无需引入 rand。 +pub fn generate_pin() -> String { + let b = uuid::Uuid::new_v4().into_bytes(); + let n = u32::from_le_bytes([b[0], b[1], b[2], b[3]]) % 1_000_000; + format!("{n:06}") +} + +fn is_private_lan(ip: &Ipv4Addr) -> bool { + let o = ip.octets(); + !ip.is_loopback() + && !ip.is_link_local() + && ((o[0] == 192 && o[1] == 168) + || o[0] == 10 + || (o[0] == 172 && (16..=31).contains(&o[1]))) +} + +/// 本机所有局域网 IPv4(过滤回环 / link-local / 虚拟网卡的非私网段)。 +pub fn local_lan_ipv4s() -> Vec { + let mut out: Vec = Vec::new(); + if let Ok(ifaces) = local_ip_address::list_afinet_netifas() { + for (_name, ip) in ifaces { + if let IpAddr::V4(v4) = ip { + if is_private_lan(&v4) { + out.push(v4); + } + } + } + } + out.sort(); + out.dedup(); + out +} + +/// 给前端展示的访问网址列表。 +pub fn access_urls(port: u16) -> Vec { + local_lan_ipv4s() + .iter() + .map(|ip| format!("https://{ip}:{port}")) + .collect() +} + +// ───────────────────────── TLS ───────────────────────── + +fn build_rustls_config() -> Result, String> { + let mut sans = vec!["localhost".to_string(), "127.0.0.1".to_string()]; + for ip in local_lan_ipv4s() { + sans.push(ip.to_string()); + } + let certified = + rcgen::generate_simple_self_signed(sans).map_err(|e| format!("rcgen: {e}"))?; + let cert_der = certified.cert.der().clone(); + let key_der = rustls::pki_types::PrivatePkcs8KeyDer::from( + certified.key_pair.serialize_der(), + ); + let provider = Arc::new(rustls::crypto::ring::default_provider()); + let config = rustls::ServerConfig::builder_with_provider(provider) + .with_safe_default_protocol_versions() + .map_err(|e| format!("tls protocol: {e}"))? + .with_no_client_auth() + .with_single_cert(vec![cert_der], key_der.into()) + .map_err(|e| format!("tls cert: {e}"))?; + Ok(Arc::new(config)) +} + +// ───────────────────────── 启动 ───────────────────────── + +struct WsState { + pin: String, + coordinator: Arc, + app: AppHandle, + /// 全局 PIN 失败计数 + 锁定截止时刻(简单防爆破;TLS+6 位 PIN 已是主防线)。 + pin_fails: Mutex<(u32, Option)>, +} + +fn build_router(state: Arc) -> Router { + Router::new() + .route("/", get(|| async { Html(assets::INDEX_HTML) })) + .route( + "/app.js", + get(|| async { ([(axum::http::header::CONTENT_TYPE, HEADER_JS)], assets::APP_JS) }), + ) + .route( + "/style.css", + get(|| async { + ([(axum::http::header::CONTENT_TYPE, HEADER_CSS)], assets::STYLE_CSS) + }), + ) + .route("/ws", get(ws_upgrade)) + .with_state(state) +} + +pub async fn start(cfg: RemoteServerConfig) -> Result { + let _ = HEADER_HTML; // index 用 axum Html() 自带 content-type + let rustls_config = build_rustls_config()?; + let acceptor = TlsAcceptor::from(rustls_config); + + let addr = SocketAddr::from(([0, 0, 0, 0], cfg.port)); + let listener = TcpListener::bind(addr).await.map_err(|e| { + if e.kind() == std::io::ErrorKind::AddrInUse { + "port-in-use".to_string() + } else { + format!("bind: {e}") + } + })?; + let bound_port = listener.local_addr().map(|a| a.port()).unwrap_or(cfg.port); + + let state = Arc::new(WsState { + pin: cfg.pin.clone(), + coordinator: cfg.coordinator, + app: cfg.app, + pin_fails: Mutex::new((0, None)), + }); + let router = build_router(state); + + let (shutdown_tx, mut shutdown_rx) = tokio::sync::oneshot::channel::<()>(); + let join = tauri::async_runtime::spawn(async move { + loop { + tokio::select! { + _ = &mut shutdown_rx => { + log::info!("[remote-input] accept loop shutting down"); + break; + } + accepted = listener.accept() => { + let (tcp, _peer) = match accepted { + Ok(x) => x, + Err(e) => { + log::warn!("[remote-input] accept error: {e}"); + continue; + } + }; + let acceptor = acceptor.clone(); + let router = router.clone(); + tokio::spawn(async move { + let tls = match acceptor.accept(tcp).await { + Ok(t) => t, + Err(_) => return, // 客户端没装证书 / 握手失败:静默 + }; + let io = TokioIo::new(tls); + let svc = hyper_util::service::TowerToHyperService::new(router); + let _ = hyper_util::server::conn::auto::Builder::new(TokioExecutor::new()) + .serve_connection_with_upgrades(io, svc) + .await; + }); + } + } + } + }); + + Ok(RemoteServerHandle { + shutdown_tx: Some(shutdown_tx), + join, + bound_port, + pin: cfg.pin, + }) +} + +// ───────────────────────── WebSocket ───────────────────────── + +async fn ws_upgrade( + State(state): State>, + ws: WebSocketUpgrade, +) -> impl IntoResponse { + ws.on_upgrade(move |socket| handle_ws(socket, state)) +} + +fn send_json(value: &T) -> Message { + Message::Text(serde_json::to_string(value).unwrap_or_else(|_| "{}".into())) +} + +/// 把后端 capsule 事件 payload 映射成手机端 status / level JSON 文本。 +fn capsule_payload_to_phone(payload: &str) -> Vec { + let v: serde_json::Value = match serde_json::from_str(payload) { + Ok(v) => v, + Err(_) => return Vec::new(), + }; + let state = v.get("state").and_then(|s| s.as_str()).unwrap_or(""); + let kind = match state { + s if s.eq_ignore_ascii_case("recording") => "recording", + s if s.eq_ignore_ascii_case("transcribing") => "transcribing", + s if s.eq_ignore_ascii_case("polishing") => "polishing", + s if s.eq_ignore_ascii_case("done") => "done", + s if s.eq_ignore_ascii_case("error") => "error", + s if s.eq_ignore_ascii_case("cancelled") => "done", + _ => "", + }; + let mut out = Vec::new(); + if !kind.is_empty() { + let inserted = v + .get("insertedChars") + .or_else(|| v.get("inserted_chars")) + .and_then(|n| n.as_u64()); + let message = v.get("message").and_then(|m| m.as_str()); + out.push( + serde_json::json!({ + "type": "status", + "kind": kind, + "insertedChars": inserted, + "message": message, + }) + .to_string(), + ); + } + if let Some(level) = v.get("level").and_then(|l| l.as_f64()) { + if state.eq_ignore_ascii_case("recording") { + out.push(serde_json::json!({"type": "level", "value": level}).to_string()); + } + } + out +} + +async fn handle_ws(mut socket: WebSocket, state: Arc) { + // 1) 握手:等第一帧 hello + PIN。 + let authed = match tokio::time::timeout(Duration::from_secs(15), socket.recv()).await { + Ok(Some(Ok(Message::Text(txt)))) => verify_hello(&txt, &state), + _ => return, // 超时 / 非文本首帧 / 断开 + }; + match authed { + AuthResult::Ok => { + let _ = socket.send(send_json(&serde_json::json!({"type":"auth","ok":true}))).await; + } + AuthResult::BadPin => { + let _ = socket + .send(send_json(&serde_json::json!({"type":"auth","ok":false,"reason":"bad-pin"}))) + .await; + return; + } + AuthResult::Locked => { + let _ = socket + .send(send_json(&serde_json::json!({"type":"auth","ok":false,"reason":"locked"}))) + .await; + return; + } + } + + // 2) 订阅 capsule 事件,转发给手机做状态显示。 + let (evt_tx, mut evt_rx) = tokio::sync::mpsc::unbounded_channel::(); + let listener_id = { + let tx = evt_tx.clone(); + state.app.listen("capsule:state", move |event| { + for msg in capsule_payload_to_phone(event.payload()) { + let _ = tx.send(msg); + } + }) + }; + + // 3) 主循环:手机上行(控制 / PCM) + 后端状态下行。 + loop { + tokio::select! { + incoming = socket.recv() => { + match incoming { + Some(Ok(Message::Binary(pcm))) => { + if pcm.len() >= 2 && pcm.len() % 2 == 0 { + state.coordinator.feed_remote_pcm(&pcm); + } + } + Some(Ok(Message::Text(txt))) => { + if !handle_control(&txt, &state, &mut socket).await { + break; + } + } + Some(Ok(Message::Close(_))) | None | Some(Err(_)) => break, + _ => {} + } + } + Some(msg) = evt_rx.recv() => { + if socket.send(Message::Text(msg)).await.is_err() { + break; + } + } + } + } + + // 4) 收尾:断连即取消未完成的远程会话,避免 ASR 句柄悬挂。 + state.app.unlisten(listener_id); + state.coordinator.cancel_remote_dictation(); +} + +/// 返回 false 表示应断开连接。 +async fn handle_control(txt: &str, state: &Arc, socket: &mut WebSocket) -> bool { + let v: serde_json::Value = match serde_json::from_str(txt) { + Ok(v) => v, + Err(_) => return true, + }; + match v.get("type").and_then(|t| t.as_str()).unwrap_or("") { + "start" => match state.coordinator.start_remote_dictation().await { + Ok(()) => {} + Err(reason) => { + let _ = socket + .send(send_json(&serde_json::json!({"type":"busy","reason":reason}))) + .await; + } + }, + "stop" => { + let _ = state.coordinator.stop_remote_dictation().await; + } + "cancel" => { + state.coordinator.cancel_remote_dictation(); + } + _ => {} + } + true +} + +enum AuthResult { + Ok, + BadPin, + Locked, +} + +fn verify_hello(txt: &str, state: &Arc) -> AuthResult { + // 锁定检查 + { + let mut guard = state.pin_fails.lock(); + if let Some(until) = guard.1 { + if Instant::now() < until { + return AuthResult::Locked; + } + // 锁定到期,重置 + *guard = (0, None); + } + } + let v: serde_json::Value = match serde_json::from_str(txt) { + Ok(v) => v, + Err(_) => return AuthResult::BadPin, + }; + let ok = v.get("type").and_then(|t| t.as_str()) == Some("hello") + && v.get("pin") + .and_then(|p| p.as_str()) + .map(|p| constant_time_eq(p.as_bytes(), state.pin.as_bytes())) + .unwrap_or(false); + if ok { + *state.pin_fails.lock() = (0, None); + AuthResult::Ok + } else { + let mut guard = state.pin_fails.lock(); + guard.0 += 1; + if guard.0 >= PIN_MAX_FAILS { + guard.1 = Some(Instant::now() + Duration::from_secs(PIN_LOCK_SECS)); + } + AuthResult::BadPin + } +} + +/// 等长常量时间比较,避免 PIN 计时侧信道。 +fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { + if a.len() != b.len() { + return false; + } + let mut diff = 0u8; + for (x, y) in a.iter().zip(b.iter()) { + diff |= x ^ y; + } + diff == 0 +} diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index 5f8c16a3..5016b279 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -724,6 +724,27 @@ pub struct UserPreferences { /// 上传 / 点赞需要带这个 header;空时上传被后端 401。 #[serde(default)] pub marketplace_dev_login: String, + /// ── 远程输入(局域网手机录音)──────────────────────────────── + /// 是否启用远程输入 HTTPS+WS 服务。默认 false(关闭,按需手动开启)。 + #[serde(default)] + pub remote_input_enabled: bool, + /// 远程输入服务监听端口(HTTPS)。默认 8443。 + #[serde(default = "default_remote_input_port")] + pub remote_input_port: u16, + /// 远程输入配对码(6 位数字)。空 = server 首次启动时随机生成并回写。 + #[serde(default)] + pub remote_input_pin: String, + /// 手机录音页默认交互方式:"toggle"(点击切换)/ "hold"(按住说话)。 + #[serde(default = "default_remote_input_mode")] + pub remote_input_default_mode: String, +} + +fn default_remote_input_port() -> u16 { + 8443 +} + +fn default_remote_input_mode() -> String { + "toggle".into() } fn default_local_asr_model() -> String { @@ -857,6 +878,14 @@ struct UserPreferencesWire { marketplace_base_url: String, #[serde(default)] marketplace_dev_login: String, + #[serde(default)] + remote_input_enabled: bool, + #[serde(default = "default_remote_input_port")] + remote_input_port: u16, + #[serde(default)] + remote_input_pin: String, + #[serde(default = "default_remote_input_mode")] + remote_input_default_mode: String, } impl Default for UserPreferencesWire { @@ -916,6 +945,10 @@ impl Default for UserPreferencesWire { audio_recording_max_entries: prefs.audio_recording_max_entries, marketplace_base_url: prefs.marketplace_base_url, marketplace_dev_login: prefs.marketplace_dev_login, + remote_input_enabled: prefs.remote_input_enabled, + remote_input_port: prefs.remote_input_port, + remote_input_pin: prefs.remote_input_pin, + remote_input_default_mode: prefs.remote_input_default_mode, } } } @@ -1004,6 +1037,10 @@ impl<'de> Deserialize<'de> for UserPreferences { audio_recording_max_entries: wire.audio_recording_max_entries, marketplace_base_url: wire.marketplace_base_url, marketplace_dev_login: wire.marketplace_dev_login, + remote_input_enabled: wire.remote_input_enabled, + remote_input_port: wire.remote_input_port, + remote_input_pin: wire.remote_input_pin, + remote_input_default_mode: wire.remote_input_default_mode, }) } } @@ -1694,6 +1731,10 @@ impl Default for UserPreferences { audio_recording_max_entries: None, marketplace_base_url: String::new(), marketplace_dev_login: String::new(), + remote_input_enabled: false, + remote_input_port: default_remote_input_port(), + remote_input_pin: String::new(), + remote_input_default_mode: default_remote_input_mode(), } } } diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 507ce497..39c0b52e 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -770,6 +770,21 @@ export const en: typeof zhCN = { ko: '한국어 (Beta)', restartHint: 'Some native menus (system tray, etc.) may require an app restart to fully switch.', }, + remoteInput: { + title: 'Remote Input', + enableLabel: 'Enable remote input', + enableDesc: 'Record from a phone/tablet browser on your LAN; speech is typed at your computer\'s cursor (HTTPS required; trust the certificate on first visit)', + portLabel: 'Port', + defaultModeLabel: 'Default recording mode', + modeToggle: 'Tap to toggle', + modeHold: 'Hold to talk', + urlLabel: 'Access URL', + pinLabel: 'Pairing code', + regeneratePin: 'Regenerate', + portInUse: 'Port {{port}} is in use, please change it', + securityHint: 'Reachable only on the same LAN and requires the pairing code; turn it off when not in use.', + certHint: 'On first visit the browser warns the certificate is untrusted — choose "Proceed".', + }, about: { tagline: 'Speak naturally, write perfectly', checkUpdate: 'Check for updates', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index fa83f525..598030e1 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -772,6 +772,21 @@ export const ja: typeof zhCN = { ko: '한국어 (Beta)', restartHint: '一部のネイティブメニュー(トレイ等)は再起動後に反映されます。', }, + remoteInput: { + title: 'リモート入力', + enableLabel: 'リモート入力を有効化', + enableDesc: 'スマホ/タブレットのブラウザから PC に接続して録音し、音声を PC のカーソル位置にリアルタイムで入力します(HTTPS が必要。初回アクセス時は証明書を信頼してください)', + portLabel: '待ち受けポート', + defaultModeLabel: '既定の録音方式', + modeToggle: 'タップで切替', + modeHold: '押し続けて話す', + urlLabel: 'アクセス URL', + pinLabel: 'ペアリングコード', + regeneratePin: '再生成', + portInUse: 'ポート {{port}} は使用中です。変更してください', + securityHint: '同一 LAN からのみアクセス可能で、ペアリングコードの入力が必要です。使わないときはオフにすることを推奨します。', + certHint: '初回アクセス時、ブラウザが証明書は信頼されていないと警告します。案内に従って「続行」を選択してください。', + }, about: { tagline: '自然に話し、きれいに書く', checkUpdate: 'アップデート確認', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index 3e3c0887..26793374 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -772,6 +772,21 @@ export const ko: typeof zhCN = { ko: '한국어 (Beta)', restartHint: '일부 네이티브 메뉴(트레이 등)는 앱 재시작 후 반영될 수 있습니다.', }, + remoteInput: { + title: '원격 입력', + enableLabel: '원격 입력 활성화', + enableDesc: '휴대폰/태블릿 브라우저를 PC에 연결해 녹음하고, 음성을 PC 커서 위치에 실시간으로 입력합니다(HTTPS 필요, 첫 접속 시 인증서를 신뢰해야 함)', + portLabel: '수신 포트', + defaultModeLabel: '기본 녹음 방식', + modeToggle: '탭하여 전환', + modeHold: '눌러서 말하기', + urlLabel: '접속 URL', + pinLabel: '페어링 코드', + regeneratePin: '재생성', + portInUse: '포트 {{port}}이(가) 사용 중입니다. 변경하세요', + securityHint: '같은 LAN에서만 접속 가능하며 페어링 코드 입력이 필요합니다. 사용하지 않을 때는 끄는 것을 권장합니다.', + certHint: '첫 접속 시 브라우저가 인증서를 신뢰할 수 없다고 경고합니다. 안내에 따라 "계속 진행"을 선택하세요.', + }, about: { tagline: '자연스럽게 말하고, 정확하게 작성하세요', checkUpdate: '업데이트 확인', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 14289086..9f7478a6 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -768,6 +768,21 @@ export const zhCN = { ko: '한국어 (Beta)', restartHint: '部分原生菜单(系统托盘等)可能需要重启 App 才会切换。', }, + remoteInput: { + title: '远程输入', + enableLabel: '启用远程输入', + enableDesc: '手机/平板浏览器连到电脑录音,语音实时落到电脑光标处(需 HTTPS,首次访问要信任证书)', + portLabel: '监听端口', + defaultModeLabel: '默认录音方式', + modeToggle: '点击切换', + modeHold: '按住说话', + urlLabel: '访问网址', + pinLabel: '配对码', + regeneratePin: '重新生成', + portInUse: '端口 {{port}} 被占用,请更换', + securityHint: '仅同一局域网可访问,需输入配对码;不用时建议关闭。', + certHint: '首次访问浏览器会提示证书不受信任,按提示选择"继续访问"。', + }, about: { tagline: '自然说话,完美书写', checkUpdate: '检查更新', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 0aafd1b4..a2315263 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -770,6 +770,21 @@ export const zhTW: typeof zhCN = { ko: '한국어 (Beta)', restartHint: '部分原生菜單(系統托盤等)可能需要重啓 App 纔會切換。', }, + remoteInput: { + title: '遠端輸入', + enableLabel: '啟用遠端輸入', + enableDesc: '手機/平板瀏覽器連到電腦錄音,語音即時落到電腦游標處(需 HTTPS,首次存取要信任憑證)', + portLabel: '監聽連接埠', + defaultModeLabel: '預設錄音方式', + modeToggle: '點擊切換', + modeHold: '按住說話', + urlLabel: '存取網址', + pinLabel: '配對碼', + regeneratePin: '重新產生', + portInUse: '連接埠 {{port}} 被佔用,請更換', + securityHint: '僅同一區域網路可存取,需輸入配對碼;不用時建議關閉。', + certHint: '首次存取瀏覽器會提示憑證不受信任,按提示選擇「繼續存取」。', + }, about: { tagline: '自然說話,完美書寫', checkUpdate: '檢查更新', diff --git a/openless-all/app/src/lib/ipc.ts b/openless-all/app/src/lib/ipc.ts index b3a249c8..30719c54 100644 --- a/openless-all/app/src/lib/ipc.ts +++ b/openless-all/app/src/lib/ipc.ts @@ -123,6 +123,10 @@ let mockSettings: UserPreferences = { audioRecordingMaxEntries: null, marketplaceBaseUrl: "https://apic.openless.top", marketplaceDevLogin: "", + remoteInputEnabled: false, + remoteInputPort: 8443, + remoteInputPin: "000000", + remoteInputDefaultMode: "toggle", } const mockFullStylePrompts: StyleSystemPrompts = { @@ -524,6 +528,31 @@ export function setSettings(prefs: UserPreferences): Promise { }) } +// ── Remote input (局域网手机录音) ────────────────────────────────────── +export interface RemoteInputStatus { + running: boolean + port: number + pin: string + urls: string[] +} + +export function getRemoteInputStatus(): Promise { + return invokeOrMock("get_remote_input_status", undefined, () => ({ + running: false, + port: 8443, + pin: "000000", + urls: [], + })) +} + +export function listLocalIps(): Promise { + return invokeOrMock("list_local_ips", undefined, () => ["192.168.1.100"]) +} + +export function regenerateRemotePin(): Promise { + return invokeOrMock("regenerate_remote_pin", undefined, () => "123456") +} + // ── Release channel (Beta opt-in) ────────────────────────────────────── // 渠道偏好与 fetch_latest_beta_release 实际效果只在 Tauri runtime 内有意义; // 浏览器开发模式下走 mock,避免设置页因 invoke 抛错而白屏。 diff --git a/openless-all/app/src/lib/stylePrefs.test.ts b/openless-all/app/src/lib/stylePrefs.test.ts index 66d08b68..22dbcf9d 100644 --- a/openless-all/app/src/lib/stylePrefs.test.ts +++ b/openless-all/app/src/lib/stylePrefs.test.ts @@ -72,6 +72,10 @@ const previousPrefs: UserPreferences = { audioRecordingMaxEntries: null, marketplaceBaseUrl: '', marketplaceDevLogin: '', + remoteInputEnabled: false, + remoteInputPort: 8443, + remoteInputPin: '000000', + remoteInputDefaultMode: 'toggle', }; const nextPrefs: UserPreferences = { diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index 9e1e660c..0bc9b352 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -315,6 +315,14 @@ export interface UserPreferences { marketplaceBaseUrl: string; /** Marketplace dev-mode 模拟登录用户名(GitHub login 风格)。生产换 OAuth token 后此字段废弃。 */ marketplaceDevLogin: string; + /** 是否启用远程输入(局域网手机录音)HTTPS+WS 服务。默认 false。 */ + remoteInputEnabled: boolean; + /** 远程输入服务监听端口(HTTPS)。默认 8443。 */ + remoteInputPort: number; + /** 远程输入配对码(6 位数字)。空 = server 首次启动时随机生成。 */ + remoteInputPin: string; + /** 手机录音页默认交互方式:'toggle'(点击切换)/ 'hold'(按住说话)。 */ + remoteInputDefaultMode: 'toggle' | 'hold'; } export interface MarketplaceListItem { diff --git a/openless-all/app/src/pages/settings/RemoteInputSection.tsx b/openless-all/app/src/pages/settings/RemoteInputSection.tsx new file mode 100644 index 00000000..342e4492 --- /dev/null +++ b/openless-all/app/src/pages/settings/RemoteInputSection.tsx @@ -0,0 +1,235 @@ +// 远程输入:在局域网用手机/平板浏览器打开一个录音页,语音实时流回电脑,复用 +// 电脑现有的「录音→ASR→润色→光标落字」管线。放在「通用」标签页里,做成可折叠组 +// (与「启动」一致,默认折叠):启停开关、监听端口、访问网址(可一键复制,带配对码)、 +// 配对码(可重置)、默认录音方式,以及证书/安全提示。 + +import { useEffect, useState, type CSSProperties } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useHotkeySettings } from '../../state/HotkeySettingsContext'; +import { Collapsible } from '../_atoms'; +import { SettingRow, Toggle, inputStyle } from './shared'; +import { + getRemoteInputStatus, + regenerateRemotePin, + isTauri, + type RemoteInputStatus, +} from '../../lib/ipc'; + +async function copyText(text: string): Promise { + try { + await navigator.clipboard.writeText(text); + return; + } catch { + // 退路:隐藏 textarea + execCommand,兼容个别不支持 async clipboard 的环境。 + const ta = document.createElement('textarea'); + ta.value = text; + ta.style.position = 'fixed'; + ta.style.opacity = '0'; + document.body.appendChild(ta); + ta.select(); + try { + document.execCommand('copy'); + } catch { + /* ignore */ + } + document.body.removeChild(ta); + } +} + +export function RemoteInputSection() { + const { t } = useTranslation(); + const { prefs, updatePrefs } = useHotkeySettings(); + const [status, setStatus] = useState(null); + const [errorPort, setErrorPort] = useState(null); + const [copied, setCopied] = useState(null); + + useEffect(() => { + let alive = true; + const refresh = () => + getRemoteInputStatus() + .then((s) => alive && setStatus(s)) + .catch(() => {}); + refresh(); + if (!isTauri) return; + const unsubs: Array<() => void> = []; + import('@tauri-apps/api/event').then(({ listen }) => { + listen('remote-input:running', () => { + setErrorPort(null); + refresh(); + }).then((u) => unsubs.push(u)); + listen('remote-input:error', (e) => { + const p = e.payload as { port?: number } | null; + if (alive) setErrorPort(p?.port ?? 0); + }).then((u) => unsubs.push(u)); + }); + return () => { + alive = false; + unsubs.forEach((u) => u()); + }; + }, []); + + if (!prefs) return null; + const enabled = prefs.remoteInputEnabled; + const mode = prefs.remoteInputDefaultMode ?? 'toggle'; + + const doCopy = async (url: string, pin: string) => { + await copyText(`${url}\n${t('settings.remoteInput.pinLabel')}:${pin}`); + setCopied(url); + window.setTimeout(() => setCopied((c) => (c === url ? null : c)), 1500); + }; + + const smallBtn: CSSProperties = { + padding: '4px 10px', + borderRadius: 8, + fontSize: 12, + cursor: 'pointer', + border: '0.5px solid var(--ol-line-strong)', + background: 'var(--ol-surface-2)', + color: 'var(--ol-ink)', + flexShrink: 0, + }; + + return ( + + + updatePrefs({ ...prefs, remoteInputEnabled: v })} + /> + + + + + updatePrefs({ + ...prefs, + remoteInputPort: Math.max( + 1, + Math.min(65535, Number(e.currentTarget.value) || 0), + ), + }) + } + /> + + + +
+ {(['toggle', 'hold'] as const).map((m) => ( + + ))} +
+
+ + {enabled && status?.running && ( + <> + +
+ {(status.urls.length + ? status.urls + : [`https://localhost:${status.port}`] + ).map((u) => ( +
+ + {u} + + +
+ ))} +
+
+ + +
+ + {status.pin} + + +
+
+ + )} + + {enabled && errorPort != null && ( +
+ {t('settings.remoteInput.portInUse', { port: errorPort })} +
+ )} + +
+ {t('settings.remoteInput.securityHint')} +
+ {t('settings.remoteInput.certHint')} +
+
+ ); +} diff --git a/openless-all/app/src/pages/settings/tabs.tsx b/openless-all/app/src/pages/settings/tabs.tsx index b1667a99..6aad0543 100644 --- a/openless-all/app/src/pages/settings/tabs.tsx +++ b/openless-all/app/src/pages/settings/tabs.tsx @@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'; import { RecordingInputSection } from './RecordingInputSection'; +import { RemoteInputSection } from './RemoteInputSection'; import { ShortcutsSection } from './ShortcutsSection'; import { LanguageSection } from './LanguageSection'; import { ProvidersSection } from './ProvidersSection'; @@ -18,6 +19,7 @@ export function GeneralTab() { return ( <> + From 03e92e74af371b1a69c4bb701838700ccd4d9190 Mon Sep 17 00:00:00 2001 From: ciddwd <572242998@qq.com> Date: Sun, 7 Jun 2026 17:45:09 +0800 Subject: [PATCH 2/8] =?UTF-8?q?feat(remote-input):=20=E5=AE=8C=E5=96=84?= =?UTF-8?q?=E6=89=8B=E6=9C=BA=E8=BF=9C=E7=A8=8B=E5=BD=95=E5=85=A5=20+=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20H5=20=E5=BD=95=E9=9F=B3=E5=87=86=E5=A4=87?= =?UTF-8?q?=E6=97=A0=E9=99=90=E5=8D=A1=E6=AD=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit H5 端 ensureAudio 加 10s 超时兜底:移动端 audioCtx.resume()/getUserMedia() 可能既不 resolve 也不 reject,导致 start 指令发不出、电脑端不弹胶囊、页面卡在准备麦克风;超时后 resetAudioContext 重建 AudioContext 并提示重试(新增 micTimeout 多语言文案)。其余为远程录入服务端/协调器/设置页及样式、图标等配套迭代。 --- openless-all/app/src-tauri/src/commands.rs | 6 + openless-all/app/src-tauri/src/coordinator.rs | 29 +- openless-all/app/src-tauri/src/lib.rs | 1 + .../src-tauri/src/remote_server/assets/app.js | 581 ++++++++++++++++-- .../src/remote_server/assets/icon.png | Bin 0 -> 9820 bytes .../src/remote_server/assets/index.html | 43 +- .../src/remote_server/assets/style.css | 261 +++++--- .../app/src-tauri/src/remote_server/mod.rs | 246 +++++++- openless-all/app/src/i18n/index.ts | 10 + openless-all/app/src/lib/ipc.ts | 5 + .../src/pages/settings/RemoteInputSection.tsx | 5 +- 11 files changed, 995 insertions(+), 192 deletions(-) create mode 100644 openless-all/app/src-tauri/src/remote_server/assets/icon.png diff --git a/openless-all/app/src-tauri/src/commands.rs b/openless-all/app/src-tauri/src/commands.rs index c727c077..96d2375a 100644 --- a/openless-all/app/src-tauri/src/commands.rs +++ b/openless-all/app/src-tauri/src/commands.rs @@ -301,6 +301,12 @@ pub fn regenerate_remote_pin(coord: CoordinatorState<'_>) -> String { coord.regenerate_remote_pin() } +/// 同步 PC 端界面语言到远程输入服务,H5 录音页据此显示对应语言。 +#[tauri::command] +pub fn set_remote_locale(coord: CoordinatorState<'_>, locale: String) { + coord.set_remote_locale(locale); +} + pub(crate) fn sync_style_pack_prefs_and_persist( coord: &Coordinator, app: &AppHandle, diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 24e7a2eb..f881be97 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -284,6 +284,9 @@ struct Inner { remote_server: Mutex>, /// 当前远程输入配对码(6 位数字)。进程内有效,不持久化(每次启动可轮换)。 remote_pin: Mutex>, + /// PC 端当前界面语言(BCP-47,如 "zh-CN")。前端切换语言时经命令同步, + /// H5 录音页据此渲染对应语言。进程内镜像,不持久化(前端会在启动/切换时重新下发)。 + remote_locale: Mutex, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -357,6 +360,7 @@ impl Coordinator { remote_audio_sink: Mutex::new(None), remote_server: Mutex::new(None), remote_pin: Mutex::new(None), + remote_locale: Mutex::new(String::from("zh-CN")), }), } } @@ -425,6 +429,7 @@ impl Coordinator { remote_audio_sink: Mutex::new(None), remote_server: Mutex::new(None), remote_pin: Mutex::new(None), + remote_locale: Mutex::new(String::from("zh-CN")), }), } } @@ -1039,10 +1044,28 @@ impl Coordinator { pub fn regenerate_remote_pin(self: &Arc) -> String { let pin = crate::remote_server::generate_pin(); *self.inner.remote_pin.lock() = Some(pin.clone()); + // 写盘持久化,否则下次启动会读回旧的持久化码、把这次重置覆盖掉。 + if let Some(app) = self.inner.app.lock().clone() { + crate::remote_server::save_pin(&app, &pin); + } self.refresh_remote_server(); pin } + /// 同步 PC 端界面语言(前端切换语言时调用)。H5 录音页据此选择显示语言。 + /// 仅接受受支持的白名单值,非法输入忽略(值会注入到 H5 的 lang,需防注入)。 + pub fn set_remote_locale(&self, locale: String) { + const SUPPORTED: [&str; 5] = ["zh-CN", "zh-TW", "en", "ja", "ko"]; + if SUPPORTED.contains(&locale.as_str()) { + *self.inner.remote_locale.lock() = locale; + } + } + + /// 当前 PC 端界面语言(供 H5 首页注入 lang)。 + pub fn remote_locale(&self) -> String { + self.inner.remote_locale.lock().clone() + } + /// 按 prefs 启停 / 重启远程输入服务。在 setup 与 prefs 变更(端口/开关)时调用。 pub fn refresh_remote_server(self: &Arc) { let coord = Arc::clone(self); @@ -1064,14 +1087,16 @@ impl Coordinator { let Some(app) = app else { return; }; - // PIN:复用进程内的 remote_pin,缺则生成。 + // PIN:进程内 remote_pin 缺失时从磁盘读持久化的(没有才新生成并写盘)—— + // 否则每次重启配对码都变,用户得反复找新码(这正是"配对码错误"的根因)。 let pin = { let mut guard = coord.inner.remote_pin.lock(); if guard.is_none() { - *guard = Some(crate::remote_server::generate_pin()); + *guard = Some(crate::remote_server::load_or_create_pin(&app)); } guard.clone().unwrap_or_default() }; + log::info!("[remote-input] 当前配对码 = {pin}(在手机上输入这个)"); let port = prefs.remote_input_port; match crate::remote_server::start(crate::remote_server::RemoteServerConfig { port, diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 90e96cd0..2cfd88f1 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -342,6 +342,7 @@ pub fn run() { commands::get_remote_input_status, commands::list_local_ips, commands::regenerate_remote_pin, + commands::set_remote_locale, commands::get_update_channel, commands::set_update_channel, commands::fetch_latest_beta_release, diff --git a/openless-all/app/src-tauri/src/remote_server/assets/app.js b/openless-all/app/src-tauri/src/remote_server/assets/app.js index 1b47fb96..4db0c8bd 100644 --- a/openless-all/app/src-tauri/src/remote_server/assets/app.js +++ b/openless-all/app/src-tauri/src/remote_server/assets/app.js @@ -2,13 +2,298 @@ * OpenLess 远程输入 — 手机端录音页 * 纯静态,无外部依赖。通过 WSS 把 16kHz/单声道/16bit LE PCM * 实时推送给 PC 端 Rust 服务。 + * + * 显示语言跟随 PC 端界面语言:Rust 在返回首页时把 window.__OL_LANG__ + * 注入成 PC 当前 locale(前端切换语言时经 set_remote_locale 命令同步)。 * ========================================================== */ (function () { 'use strict'; + // ============================================================ + // i18n —— 文案字典(与 PC 端 src/i18n 对齐的 5 种语言) + // ============================================================ + var I18N = { + 'zh-CN': { + title: 'OpenLess 远程输入', + brandTitle: 'OpenLess 远程输入', + brandSub: '在手机上录音,实时输入到电脑', + pinFieldLabel: '配对码(电脑上显示的 6 位数字)', + btnConnect: '连接', + btnConnecting: '连接中…', + modeToggle: '点按', + modeHold: '按住', + offlineTitle: '连接已断开', + offlineSub: '与电脑的连接已中断。', + btnReconnect: '重新连接', + certTip: '首次访问浏览器会提示“连接不安全”(本地自签名证书)。Android Chrome:点“高级”→“继续前往”;iOS Safari:点“显示详情”→“访问此网站”。', + tipToggle: '点击大按钮开始录音,再次点击结束并识别。', + tipHold: '按住大按钮说话,松开结束并识别。', + labelToggleIdle: '点击开始', + labelToggleRec: '点击结束', + labelHoldIdle: '按住说话', + labelHoldRec: '松开结束', + ready: '准备就绪', + preparingMic: '正在准备麦克风…', + statusRecording: '🎤 录音中', + statusTranscribing: '🔄 识别中', + statusPolishing: '✨ 润色中', + statusDone: '✅ 已输入 {n} 字', + cancelled: '已取消', + connLost: '连接已断开', + errPinFormat: '请输入 6 位数字配对码。', + errPinWrong: '配对码错误,请重试。', + errPinLocked: '配对已锁定,请在电脑上重新生成配对码。', + errConnFail: '连接失败。多半是手机未信任电脑证书,请先信任证书后重试。', + errConnCreate: '无法建立连接,请检查网络。', + errConnTimeout: '连接超时。多半是手机未信任电脑证书,请按下方说明信任后重试。', + busy: '电脑忙:{reason}', + busyDefault: '请稍候', + micDenied: '❌ 麦克风权限被拒绝,请在浏览器设置中允许。', + micNotFound: '❌ 未找到可用麦克风。', + micBusy: '❌ 麦克风被其他应用占用。', + micTimeout: '❌ 麦克风准备超时,请重试。', + micUnknown: '❌ 无法启动录音{name}。', + errGeneric: '发生错误', + helpTitle: '连不上?多半是手机没信任证书', + helpAndroid: '① 安卓 / 一般情况:用浏览器无痕模式打开本页,出现“不安全”警告时选“继续前往”,再输入配对码连接。', + helpIos: '② iOS Safari:用无痕模式打开本页,出现“不安全”提示时点“显示详情 → 访问此网站”,再输入配对码连接(无需安装证书)。', + helpDownloadCert: '⬇ 下载并安装证书', + helpCopyLink: '⧉ 复制链接', + helpCopied: '已复制 ✓', + }, + 'zh-TW': { + title: 'OpenLess 遠端輸入', + brandTitle: 'OpenLess 遠端輸入', + brandSub: '在手機上錄音,即時輸入到電腦', + pinFieldLabel: '配對碼(電腦上顯示的 6 位數字)', + btnConnect: '連線', + btnConnecting: '連線中…', + modeToggle: '點按', + modeHold: '按住', + offlineTitle: '連線已中斷', + offlineSub: '與電腦的連線已中斷。', + btnReconnect: '重新連線', + certTip: '首次造訪瀏覽器會提示「連線不安全」(本機自簽憑證)。Android Chrome:點「進階」→「繼續前往」;iOS Safari:點「顯示詳細資訊」→「瀏覽此網站」。', + tipToggle: '點擊大按鈕開始錄音,再次點擊結束並辨識。', + tipHold: '按住大按鈕說話,放開結束並辨識。', + labelToggleIdle: '點擊開始', + labelToggleRec: '點擊結束', + labelHoldIdle: '按住說話', + labelHoldRec: '放開結束', + ready: '準備就緒', + preparingMic: '正在準備麥克風…', + statusRecording: '🎤 錄音中', + statusTranscribing: '🔄 辨識中', + statusPolishing: '✨ 潤飾中', + statusDone: '✅ 已輸入 {n} 字', + cancelled: '已取消', + connLost: '連線已中斷', + errPinFormat: '請輸入 6 位數字配對碼。', + errPinWrong: '配對碼錯誤,請重試。', + errPinLocked: '配對已鎖定,請在電腦上重新產生配對碼。', + errConnFail: '連線失敗。多半是手機未信任電腦憑證,請先信任憑證後重試。', + errConnCreate: '無法建立連線,請檢查網路。', + errConnTimeout: '連線逾時。多半是手機未信任電腦憑證,請依下方說明信任後重試。', + busy: '電腦忙碌:{reason}', + busyDefault: '請稍候', + micDenied: '❌ 麥克風權限遭拒,請在瀏覽器設定中允許。', + micNotFound: '❌ 找不到可用的麥克風。', + micBusy: '❌ 麥克風被其他應用程式佔用。', + micTimeout: '❌ 麥克風準備逾時,請重試。', + micUnknown: '❌ 無法啟動錄音{name}。', + errGeneric: '發生錯誤', + helpTitle: '連不上?多半是手機沒信任憑證', + helpAndroid: '① 安卓 / 一般情況:用瀏覽器無痕模式開啟本頁,出現“不安全”警告時選“繼續前往”,再輸入配對碼連線。', + helpIos: '② iOS Safari:用無痕模式開啟本頁,出現“不安全”提示時點“顯示詳細資訊 → 瀏覽此網站”,再輸入配對碼連線(無需安裝憑證)。', + helpDownloadCert: '⬇ 下載並安裝憑證', + helpCopyLink: '⧉ 複製連結', + helpCopied: '已複製 ✓', + }, + en: { + title: 'OpenLess Remote Input', + brandTitle: 'OpenLess Remote Input', + brandSub: 'Record on your phone, type to your computer in real time', + pinFieldLabel: 'Pairing code (6 digits shown on your computer)', + btnConnect: 'Connect', + btnConnecting: 'Connecting…', + modeToggle: 'Tap', + modeHold: 'Hold', + offlineTitle: 'Disconnected', + offlineSub: 'The connection to your computer was lost.', + btnReconnect: 'Reconnect', + certTip: 'On first visit the browser will warn "Not secure" (local self-signed certificate). Android Chrome: tap "Advanced" → "Proceed"; iOS Safari: tap "Show Details" → "visit this website".', + tipToggle: 'Tap the big button to start recording, tap again to finish and transcribe.', + tipHold: 'Hold the big button to talk, release to finish and transcribe.', + labelToggleIdle: 'Tap to start', + labelToggleRec: 'Tap to stop', + labelHoldIdle: 'Hold to talk', + labelHoldRec: 'Release to stop', + ready: 'Ready', + preparingMic: 'Preparing microphone…', + statusRecording: '🎤 Recording', + statusTranscribing: '🔄 Transcribing', + statusPolishing: '✨ Polishing', + statusDone: '✅ Inserted {n} chars', + cancelled: 'Cancelled', + connLost: 'Connection lost', + errPinFormat: 'Please enter the 6-digit pairing code.', + errPinWrong: 'Wrong pairing code, please try again.', + errPinLocked: 'Pairing locked. Please regenerate the code on your computer.', + errConnFail: 'Connection failed — usually the phone does not trust the computer certificate. Trust it, then retry.', + errConnCreate: 'Could not connect. Please check your network.', + errConnTimeout: 'Connection timed out — the phone likely does not trust the certificate. Follow the steps below to trust it, then retry.', + busy: 'Computer busy: {reason}', + busyDefault: 'please wait', + micDenied: '❌ Microphone permission denied. Please allow it in browser settings.', + micNotFound: '❌ No microphone available.', + micBusy: '❌ Microphone is in use by another app.', + micTimeout: '❌ Microphone setup timed out. Please try again.', + micUnknown: '❌ Could not start recording{name}.', + errGeneric: 'An error occurred', + helpTitle: "Can't connect? The phone probably doesn't trust the certificate", + helpAndroid: '① Android / general: open this page in an incognito tab, choose "Proceed" on the "Not secure" warning, then enter the pairing code.', + helpIos: '② iOS Safari: open this page in an incognito tab; on the "Not Private" warning tap "Show Details → visit this website", then enter the code (no certificate install needed).', + helpDownloadCert: '⬇ Download & install cert', + helpCopyLink: '⧉ Copy link', + helpCopied: 'Copied ✓', + }, + ja: { + title: 'OpenLess リモート入力', + brandTitle: 'OpenLess リモート入力', + brandSub: 'スマホで録音し、リアルタイムでパソコンに入力', + pinFieldLabel: 'ペアリングコード(パソコンに表示される6桁の数字)', + btnConnect: '接続', + btnConnecting: '接続中…', + modeToggle: 'タップ', + modeHold: '長押し', + offlineTitle: '接続が切断されました', + offlineSub: 'パソコンとの接続が切断されました。', + btnReconnect: '再接続', + certTip: '初回アクセス時、ブラウザに「保護されていません」と表示されます(ローカル自己署名証明書)。Android Chrome:「詳細設定」→「アクセスする」、iOS Safari:「詳細を表示」→「このWebサイトを閲覧」をタップしてください。', + tipToggle: '大きいボタンをタップして録音開始、もう一度タップで終了して認識します。', + tipHold: '大きいボタンを長押しして話し、離すと終了して認識します。', + labelToggleIdle: 'タップで開始', + labelToggleRec: 'タップで終了', + labelHoldIdle: '長押しで話す', + labelHoldRec: '離して終了', + ready: '準備完了', + preparingMic: 'マイクを準備中…', + statusRecording: '🎤 録音中', + statusTranscribing: '🔄 認識中', + statusPolishing: '✨ 整文中', + statusDone: '✅ {n}文字を入力しました', + cancelled: 'キャンセルしました', + connLost: '接続が切断されました', + errPinFormat: '6桁の数字のペアリングコードを入力してください。', + errPinWrong: 'ペアリングコードが違います。もう一度お試しください。', + errPinLocked: 'ペアリングがロックされました。パソコンでコードを再生成してください。', + errConnFail: '接続に失敗しました。多くはスマホがパソコンの証明書を信頼していないためです。証明書を信頼してから再試行してください。', + errConnCreate: '接続できません。ネットワークを確認してください。', + errConnTimeout: '接続がタイムアウトしました。多くはスマホが証明書を信頼していないためです。下の手順で信頼してから再試行してください。', + busy: 'パソコンがビジー状態です:{reason}', + busyDefault: 'お待ちください', + micDenied: '❌ マイクの許可が拒否されました。ブラウザの設定で許可してください。', + micNotFound: '❌ 利用可能なマイクが見つかりません。', + micBusy: '❌ マイクが他のアプリで使用されています。', + micTimeout: '❌ マイクの準備がタイムアウトしました。もう一度お試しください。', + micUnknown: '❌ 録音を開始できませんでした{name}。', + errGeneric: 'エラーが発生しました', + helpTitle: '接続できない?多くは証明書が信頼されていません', + helpAndroid: '① Android / 一般:ブラウザのシークレットモードで本ページを開き、「保護されていません」で「アクセスする」を選び、ペアリングコードを入力。', + helpIos: '② iOS Safari:シークレットモードで本ページを開き、「安全ではありません」で「詳細を表示 → このWebサイトにアクセス」をタップしてコードを入力(証明書のインストール不要)。', + helpDownloadCert: '⬇ 証明書をインストール', + helpCopyLink: '⧉ リンクをコピー', + helpCopied: 'コピーしました ✓', + }, + ko: { + title: 'OpenLess 원격 입력', + brandTitle: 'OpenLess 원격 입력', + brandSub: '휴대폰으로 녹음하여 실시간으로 컴퓨터에 입력', + pinFieldLabel: '페어링 코드 (컴퓨터에 표시된 6자리 숫자)', + btnConnect: '연결', + btnConnecting: '연결 중…', + modeToggle: '탭', + modeHold: '길게 누르기', + offlineTitle: '연결이 끊겼습니다', + offlineSub: '컴퓨터와의 연결이 끊겼습니다.', + btnReconnect: '다시 연결', + certTip: '처음 접속하면 브라우저에 "안전하지 않음" 경고가 표시됩니다(로컬 자체 서명 인증서). Android Chrome: "고급" → "계속 진행"; iOS Safari: "세부정보 표시" → "이 웹사이트 방문"을 탭하세요.', + tipToggle: '큰 버튼을 탭하여 녹음을 시작하고, 다시 탭하면 종료 후 인식합니다.', + tipHold: '큰 버튼을 길게 눌러 말하고, 떼면 종료 후 인식합니다.', + labelToggleIdle: '탭하여 시작', + labelToggleRec: '탭하여 종료', + labelHoldIdle: '눌러서 말하기', + labelHoldRec: '떼면 종료', + ready: '준비 완료', + preparingMic: '마이크 준비 중…', + statusRecording: '🎤 녹음 중', + statusTranscribing: '🔄 인식 중', + statusPolishing: '✨ 다듬는 중', + statusDone: '✅ {n}자 입력함', + cancelled: '취소됨', + connLost: '연결이 끊겼습니다', + errPinFormat: '6자리 숫자 페어링 코드를 입력하세요.', + errPinWrong: '페어링 코드가 잘못되었습니다. 다시 시도하세요.', + errPinLocked: '페어링이 잠겼습니다. 컴퓨터에서 코드를 다시 생성하세요.', + errConnFail: '연결에 실패했습니다. 대개 휴대폰이 컴퓨터 인증서를 신뢰하지 않기 때문입니다. 인증서를 신뢰한 후 다시 시도하세요.', + errConnCreate: '연결할 수 없습니다. 네트워크를 확인하세요.', + errConnTimeout: '연결 시간이 초과되었습니다. 대개 인증서를 신뢰하지 않기 때문입니다. 아래 안내대로 신뢰 후 다시 시도하세요.', + busy: '컴퓨터가 사용 중입니다: {reason}', + busyDefault: '잠시 기다려 주세요', + micDenied: '❌ 마이크 권한이 거부되었습니다. 브라우저 설정에서 허용하세요.', + micNotFound: '❌ 사용 가능한 마이크가 없습니다.', + micBusy: '❌ 마이크가 다른 앱에서 사용 중입니다.', + micTimeout: '❌ 마이크 준비 시간이 초과되었습니다. 다시 시도하세요.', + micUnknown: '❌ 녹음을 시작할 수 없습니다{name}.', + errGeneric: '오류가 발생했습니다', + helpTitle: '연결이 안 되나요? 대개 인증서를 신뢰하지 않아서입니다', + helpAndroid: '① Android / 일반: 시크릿 모드로 이 페이지를 열고 "안전하지 않음" 경고에서 "계속"을 선택한 뒤 페어링 코드를 입력하세요.', + helpIos: '② iOS Safari: 시크릿 모드로 이 페이지를 열고 "안전하지 않음" 경고에서 "세부사항 표시 → 이 웹사이트 방문"을 누른 뒤 코드를 입력하세요(인증서 설치 불필요).', + helpDownloadCert: '⬇ 인증서 설치', + helpCopyLink: '⧉ 링크 복사', + helpCopied: '복사됨 ✓', + }, + }; + + // 解析显示语言:优先 PC 注入的 window.__OL_LANG__,回退手机系统语言。 + var LANG = (function () { + var supported = { 'zh-CN': 1, 'zh-TW': 1, en: 1, ja: 1, ko: 1 }; + var injected = (window.__OL_LANG__ || '').trim(); + if (supported[injected]) return injected; + var nav = (navigator.language || '').toLowerCase(); + if (nav.indexOf('zh') === 0) { + if (nav.indexOf('hant') >= 0 || nav.indexOf('tw') >= 0 || nav.indexOf('hk') >= 0 || nav.indexOf('mo') >= 0) return 'zh-TW'; + return 'zh-CN'; + } + if (nav.indexOf('ja') === 0) return 'ja'; + if (nav.indexOf('ko') === 0) return 'ko'; + if (nav.indexOf('en') === 0) return 'en'; + return 'zh-CN'; + })(); + var L = I18N[LANG] || I18N['zh-CN']; + + // 极简插值:把 "{n}" / "{reason}" / "{name}" 替换成对应值。 + function fmt(tpl, vars) { + return String(tpl).replace(/\{(\w+)\}/g, function (_, k) { + return (vars && vars[k] != null) ? vars[k] : ''; + }); + } + + // 把 index.html 里带 data-i18n 的静态文案按当前语言渲染。 + function applyStaticI18n() { + try { document.title = L.title; } catch (e) {} + var nodes = document.querySelectorAll('[data-i18n]'); + for (var i = 0; i < nodes.length; i++) { + var key = nodes[i].getAttribute('data-i18n'); + if (L[key] != null) nodes[i].textContent = L[key]; + } + } + // ---------- 常量 ---------- var TARGET_SR = 16000; // 目标采样率,必须与 PC 端一致 - var MODE_KEY = 'ol_remote_mode'; // localStorage 键 + var MODE_KEY = 'ol_remote_mode'; // localStorage 键:录音方式 + var PIN_KEY = 'ol_remote_pin'; // localStorage 键:上次成功的配对码 + var MIC_PREP_TIMEOUT_MS = 10000; // 麦克风准备超时:超过则判失败让用户重试,避免无限卡"准备中" // ---------- DOM ---------- var $ = function (id) { return document.getElementById(id); }; @@ -26,11 +311,11 @@ var statusText = $('status-text'); var levelBar = $('level-bar'); var recTip = $('rec-tip'); - var connDot = $('conn-dot'); var modeSwitch = $('mode-switch'); var btnReconnect = $('btn-reconnect'); var offlineReason = $('offline-reason'); + var copyCertBtn = $('copy-cert-link'); // ---------- 状态 ---------- var ws = null; @@ -51,6 +336,22 @@ // ScriptProcessor 兜底用的重采样状态(跨块保留) var resampleState = { phase: 0, last: 0, hasLast: false }; + // ============================================================ + // 配对码持久化(localStorage) + // ============================================================ + function readPin() { + try { + var p = localStorage.getItem(PIN_KEY); + return /^\d{6}$/.test(p || '') ? p : ''; + } catch (e) { return ''; } + } + function writePin(p) { + try { if (/^\d{6}$/.test(p)) localStorage.setItem(PIN_KEY, p); } catch (e) {} + } + function clearPin() { + try { localStorage.removeItem(PIN_KEY); } catch (e) {} + } + // ============================================================ // 屏幕切换 // ============================================================ @@ -79,12 +380,12 @@ btns[i].classList.toggle('active', btns[i].getAttribute('data-mode') === mode); } if (mode === 'hold') { - recTip.textContent = '按住大按钮说话,松开结束并识别。'; - recordLabel.textContent = recording ? '松开结束' : '按住说话'; + recTip.textContent = L.tipHold; + recordLabel.textContent = recording ? L.labelHoldRec : L.labelHoldIdle; recordBtn.style.touchAction = 'none'; // hold 防滚动 } else { - recTip.textContent = '点击大按钮开始录音,再次点击结束并识别。'; - recordLabel.textContent = recording ? '点击结束' : '点击开始'; + recTip.textContent = L.tipToggle; + recordLabel.textContent = recording ? L.labelToggleRec : L.labelToggleIdle; recordBtn.style.touchAction = 'manipulation'; } } @@ -116,6 +417,16 @@ levelBar.style.width = (v * 100).toFixed(1) + '%'; } + // done 后过几秒自动回到"准备就绪",方便直接开始下一次,而不是一直停在结果上。 + var readyTimer = null; + function scheduleReady() { + if (readyTimer) clearTimeout(readyTimer); + readyTimer = setTimeout(function () { + readyTimer = null; + if (!recording && authed) setStatus(L.ready, null); + }, 2500); + } + // ============================================================ // WebSocket // ============================================================ @@ -125,6 +436,26 @@ } } + // 连接看门狗:wss 握手或认证在 12s 内没完成,几乎都是手机没信任电脑证书 + // (iOS Safari 对自签名 wss 不复用页面级证书例外)。与其无限"连接中",不如回到 + // 配对屏给出明确提示,引导用户去信任证书。 + var connectTimer = null; + function armConnectTimeout() { + clearConnectTimeout(); + connectTimer = setTimeout(function () { + connectTimer = null; + if (!authed) { + closeWS(); + showScreen('pin'); + showPinError(L.errConnTimeout); + resetConnectBtn(); + } + }, 12000); + } + function clearConnectTimeout() { + if (connectTimer) { clearTimeout(connectTimer); connectTimer = null; } + } + function connect(pin) { lastPin = pin; closeWS(); // 清理旧连接 @@ -135,11 +466,12 @@ try { ws = new WebSocket(url); } catch (e) { - showPinError('无法建立连接,请检查网络。'); + showPinError(L.errConnCreate); resetConnectBtn(); return; } ws.binaryType = 'arraybuffer'; + armConnectTimeout(); // 看门狗:握手/认证迟迟不完成 → 多半是证书没被信任 ws.onopen = function () { // 连上立即握手 @@ -158,24 +490,28 @@ }; ws.onclose = function () { + clearConnectTimeout(); var wasAuthed = authed; authed = false; recording = false; teardownAudio(); if (wasAuthed) { // 已进入录音屏后断开 → 断线屏 - offlineReason.textContent = '与电脑的连接已中断。'; + offlineReason.textContent = L.offlineSub; showScreen('offline'); - } else if (!isPinScreen()) { - // 连接过程中失败 + } else { + // 未认证就关闭(握手被拒/证书不受信任/网络中断)。无论当前是否在配对屏都给出 + // 明确提示 —— 否则(尤其安卓 Chrome 对不受信任的自签名 wss 会立刻 onclose) + // 用户只看到按钮闪一下变回"连接",完全不知道发生了什么。 showScreen('pin'); - showPinError('连接失败,请确认电脑端服务正在运行。'); + showPinError(L.errConnFail); } resetConnectBtn(); }; } function closeWS() { + clearConnectTimeout(); if (ws) { ws.onopen = ws.onmessage = ws.onerror = ws.onclose = null; try { ws.close(); } catch (e) {} @@ -191,12 +527,13 @@ if (msg.ok) { authed = true; busy = false; + clearConnectTimeout(); + writePin(lastPin); // 配对成功 → 记住配对码,刷新后免重输 enterRecScreen(); } else { authed = false; - var reason = msg.reason === 'locked' - ? '配对已锁定,请在电脑上重新生成配对码。' - : '配对码错误,请重试。'; + clearPin(); // 配对码失效(错误/锁定)→ 清除,避免下次自动重连又失败 + var reason = msg.reason === 'locked' ? L.errPinLocked : L.errPinWrong; closeWS(); showScreen('pin'); showPinError(reason); @@ -217,12 +554,12 @@ recording = false; teardownAudioCapture(); // 停止采集但保留 ctx updateRecordBtnUI(); - setStatus('电脑忙:' + (msg.reason || '请稍候'), 'error'); + setStatus(fmt(L.busy, { reason: msg.reason || L.busyDefault }), 'error'); // 短暂后解除忙态,允许重试 setTimeout(function () { busy = false; updateRecordBtnUI(); - if (!recording) setStatus('准备就绪', null); + if (!recording) setStatus(L.ready, null); }, 1500); break; } @@ -231,21 +568,22 @@ function applyStatusKind(msg) { switch (msg.kind) { case 'recording': - setStatus('🎤 录音中', 'work'); + setStatus(L.statusRecording, 'work'); break; case 'transcribing': - setStatus('🔄 识别中', 'work'); + setStatus(L.statusTranscribing, 'work'); break; case 'polishing': - setStatus('✨ 润色中', 'work'); + setStatus(L.statusPolishing, 'work'); break; case 'done': var n = (typeof msg.insertedChars === 'number') ? msg.insertedChars : 0; - setStatus('✅ 已输入 ' + n + ' 字', 'ok'); + setStatus(fmt(L.statusDone, { n: n }), 'ok'); setLevel(0); + scheduleReady(); break; case 'error': - setStatus('❌ ' + (msg.message || '发生错误'), 'error'); + setStatus('❌ ' + (msg.message || L.errGeneric), 'error'); setLevel(0); break; default: @@ -261,10 +599,9 @@ function enterRecScreen() { showPinError(''); showScreen('rec'); - connDot.style.background = 'var(--ok)'; syncModeUI(); updateRecordBtnUI(); - setStatus('准备就绪', null); + setStatus(L.ready, null); setLevel(0); } @@ -285,12 +622,12 @@ function doConnect() { var pin = (pinInput.value || '').replace(/\D+/g, ''); if (pin.length !== 6) { - showPinError('请输入 6 位数字配对码。'); + showPinError(L.errPinFormat); return; } showPinError(''); btnConnect.disabled = true; - btnConnect.textContent = '连接中…'; + btnConnect.textContent = L.btnConnecting; connect(pin); } @@ -305,7 +642,7 @@ } function resetConnectBtn() { btnConnect.disabled = false; - btnConnect.textContent = '连接'; + btnConnect.textContent = L.btnConnect; } // 重新连接 @@ -313,11 +650,42 @@ showScreen('pin'); showPinError(''); resetConnectBtn(); - if (lastPin) { - pinInput.value = lastPin; + var p = lastPin || readPin(); + if (p) { + pinInput.value = p; + doConnect(); // 有配对码直接重连,省去再点一次 } }); + // 复制证书下载链接 —— 方便换个浏览器打开,或发给自己。 + function fallbackCopyText(text, cb) { + try { + var ta = document.createElement('textarea'); + ta.value = text; + ta.style.position = 'fixed'; + ta.style.opacity = '0'; + document.body.appendChild(ta); + ta.select(); + document.execCommand('copy'); + document.body.removeChild(ta); + if (cb) cb(); + } catch (e) {} + } + if (copyCertBtn) { + copyCertBtn.addEventListener('click', function () { + var url = location.origin + '/cert.cer'; + var ok = function () { + copyCertBtn.textContent = L.helpCopied; + setTimeout(function () { copyCertBtn.textContent = L.helpCopyLink; }, 1500); + }; + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(url).then(ok, function () { fallbackCopyText(url, ok); }); + } else { + fallbackCopyText(url, ok); + } + }); + } + // ============================================================ // 录音按钮交互(toggle / hold) // ============================================================ @@ -325,9 +693,9 @@ recordBtn.classList.toggle('recording', recording); recordBtn.classList.toggle('busy', busy && !recording); if (recording) { - recordLabel.textContent = (mode === 'hold') ? '松开结束' : '点击结束'; + recordLabel.textContent = (mode === 'hold') ? L.labelHoldRec : L.labelToggleRec; } else { - recordLabel.textContent = (mode === 'hold') ? '按住说话' : '点击开始'; + recordLabel.textContent = (mode === 'hold') ? L.labelHoldIdle : L.labelToggleIdle; } } @@ -339,44 +707,69 @@ else startRecording(); }); - // hold 模式:按下/抬起/取消 + // hold 模式:按下开始;松开/取消结束。 + // 关键:用 document 级监听兜底"松开"事件。移动端 setPointerCapture 在动画/重排/ + // 系统权限弹窗时可能丢失,导致 recordBtn 自身的 pointerup 收不到 —— 表现为"手已 + // 松开却还在录音,得再点一下才停"。改为按下时在 document 上挂一次性的 pointerup/ + // pointercancel,无论指针最终在哪释放都能结束录音。 + var holdEndHandler = null; + function attachHoldEnd() { + if (holdEndHandler) return; + holdEndHandler = function () { + if (recording) stopRecording(); // stopRecording 内部会 detachHoldEnd + else detachHoldEnd(); + }; + document.addEventListener('pointerup', holdEndHandler, true); + document.addEventListener('pointercancel', holdEndHandler, true); + } + function detachHoldEnd() { + if (!holdEndHandler) return; + document.removeEventListener('pointerup', holdEndHandler, true); + document.removeEventListener('pointercancel', holdEndHandler, true); + holdEndHandler = null; + } + recordBtn.addEventListener('pointerdown', function (e) { if (mode !== 'hold') return; if (!authed || busy) return; e.preventDefault(); - try { recordBtn.setPointerCapture(e.pointerId); } catch (err) {} + attachHoldEnd(); if (!recording) startRecording(); }); - recordBtn.addEventListener('pointerup', function (e) { - if (mode !== 'hold') return; - e.preventDefault(); - if (recording) stopRecording(); - }); - recordBtn.addEventListener('pointercancel', function () { - if (mode !== 'hold') return; - if (recording) cancelRecording(); // 来电/切后台 → 丢弃 - }); - // hold 时指针滑出按钮也按抬起处理(防止卡在录音态) - recordBtn.addEventListener('lostpointercapture', function () { - if (mode !== 'hold') return; - if (recording) stopRecording(); - }); // ============================================================ // 录音流程 // ============================================================ + // 给可能"永久 pending"的 Promise 兜底超时。移动端 audioCtx.resume() / getUserMedia() + // 在息屏/切后台/被占用时可能既不 resolve 也不 reject,整条 ensureAudio 链就永久卡住 —— + // start 指令发不出去、电脑端不弹胶囊,H5 一直停在"正在准备麦克风…"。超时即判失败,复位 + // 状态并提示重试,而不是无限等待。 + function withTimeout(promise, ms, tag) { + return new Promise(function (resolve, reject) { + var timer = setTimeout(function () { + var err = new Error(tag || 'TIMEOUT'); + err.name = tag || 'TIMEOUT'; + reject(err); + }, ms); + promise.then( + function (v) { clearTimeout(timer); resolve(v); }, + function (e) { clearTimeout(timer); reject(e); } + ); + }); + } + function startRecording() { if (recording) return; if (!ws || ws.readyState !== 1) { - setStatus('连接已断开', 'error'); + setStatus(L.connLost, 'error'); return; } // 先乐观置态,保证 iOS 在手势同步栈内 resume() recording = true; updateRecordBtnUI(); - setStatus('正在准备麦克风…', 'work'); + setStatus(L.preparingMic, 'work'); - ensureAudio() + withTimeout(ensureAudio(), MIC_PREP_TIMEOUT_MS, 'TIMEOUT') .then(function () { if (!recording) { // 期间已被取消/松手 @@ -384,26 +777,32 @@ return; } wsSendJSON({ type: 'start' }); - setStatus('🎤 录音中', 'work'); + setStatus(L.statusRecording, 'work'); }) .catch(function (err) { recording = false; + // 超时多半是 audioCtx 卡死(resume 永不 settle),彻底重建,否则下次重试会继续卡在 + // 同一个坏 ctx 上;非超时错误只需停采集链。 + if (err && err.name === 'TIMEOUT') resetAudioContext(); + else teardownAudioCapture(); updateRecordBtnUI(); - setStatus(micErrorText(err), 'error'); + setStatus(err && err.name === 'TIMEOUT' ? L.micTimeout : micErrorText(err), 'error'); }); } function stopRecording() { + detachHoldEnd(); if (!recording) return; recording = false; updateRecordBtnUI(); teardownAudioCapture(); wsSendJSON({ type: 'stop' }); - setStatus('🔄 识别中', 'work'); + setStatus(L.statusTranscribing, 'work'); setLevel(0); } function cancelRecording() { + detachHoldEnd(); if (!recording) { // 即便未在录音也确保采集停掉 teardownAudioCapture(); @@ -413,22 +812,22 @@ updateRecordBtnUI(); teardownAudioCapture(); wsSendJSON({ type: 'cancel' }); - setStatus('已取消', null); + setStatus(L.cancelled, null); setLevel(0); } function micErrorText(err) { var name = err && err.name ? err.name : ''; if (name === 'NotAllowedError' || name === 'SecurityError') { - return '❌ 麦克风权限被拒绝,请在浏览器设置中允许。'; + return L.micDenied; } if (name === 'NotFoundError' || name === 'OverconstrainedError') { - return '❌ 未找到可用麦克风。'; + return L.micNotFound; } if (name === 'NotReadableError') { - return '❌ 麦克风被其他应用占用。'; + return L.micBusy; } - return '❌ 无法启动录音' + (name ? '(' + name + ')' : '') + '。'; + return fmt(L.micUnknown, { name: name ? '(' + name + ')' : '' }); } // ============================================================ @@ -473,8 +872,9 @@ }); }) .then(function (stream) { - // 3) 建立采集图(若已建好则跳过) - if (sourceNode) return; + // 3) 建立采集图(若已建好则跳过)。audioCtx 可能在准备超时后被 resetAudioContext + // 置空(本次 getUserMedia 迟到 resolve),此时直接放弃,避免对 null ctx 建图报错。 + if (sourceNode || !audioCtx || !stream) return; sourceNode = audioCtx.createMediaStreamSource(stream); return buildCaptureGraph(); }); @@ -646,7 +1046,27 @@ if (!recording) return; if (ws && ws.readyState === 1 && buf && buf.byteLength) { try { ws.send(buf); } catch (e) {} + updateLocalLevel(buf); + } + } + + // 本地音量可视化:直接用即将上传的 Int16 PCM 算 RMS。远程模式下 PC 端没有麦克风 + // 电平源(不开本地 cpal),所以电平条由手机端自己的音频驱动 —— 实时,且不依赖后端事件。 + var lastLevelAt = 0; + function updateLocalLevel(buf) { + var now = (window.performance && performance.now) ? performance.now() : 0; + if (now && now - lastLevelAt < 50) return; // 限到 ~20Hz,避免过度刷新 DOM + lastLevelAt = now; + var n = buf.byteLength >> 1; + if (n === 0) return; + var dv = new DataView(buf); + var sum = 0; + for (var i = 0; i < n; i++) { + var s = dv.getInt16(i * 2, true) / 32768; + sum += s * s; } + var rms = Math.sqrt(sum / n); + setLevel(Math.min(1, rms * 3.5)); // 适度放大,让正常说话有明显跳动 } // ============================================================ @@ -694,6 +1114,24 @@ } } + // 准备超时后的硬复位:停麦克风轨道并彻底关闭 audioCtx,使下次 ensureAudio 从零重建。 + // 与 teardownAudio 的区别:这里 close 并置空 audioCtx —— 超时根因往往是 ctx 自身坏掉 + // (resume 永不 settle),保留它只会让下次继续卡。 + function resetAudioContext() { + teardownAudioCapture(); + if (mediaStream) { + try { + var tracks = mediaStream.getTracks(); + for (var i = 0; i < tracks.length; i++) tracks[i].stop(); + } catch (e) {} + mediaStream = null; + } + if (audioCtx) { + try { audioCtx.close(); } catch (e) {} + audioCtx = null; + } + } + // ============================================================ // 页面可见性:切后台时若在 hold 录音则取消,避免半截音频 // ============================================================ @@ -707,11 +1145,32 @@ // 初始化 // ============================================================ function init() { + // iOS Safari 怪癖兜底:页面"首次加载"后,页面内 wss 的证书信任不生效 —— 首次连接 + // 会卡在 TLS 握手→超时,手动刷新一次就好(已用日志证实:首次 TCP 到了却不升级,刷新 + // 后立刻 WS 升级成功)。这里把那一下"刷新"自动化:每个浏览器会话首次加载时静默 + // reload 一次,之后再初始化+自动连接,wss 握手就能成功。sessionStorage 标记保证只刷 + // 一次、不会死循环;手动刷新(同标签)不会重复触发,新标签/重开才会再刷。 + var reloadedOnce = false; + try { reloadedOnce = sessionStorage.getItem('ol_reloaded_once') === '1'; } catch (e) {} + if (!reloadedOnce) { + try { sessionStorage.setItem('ol_reloaded_once', '1'); } catch (e) {} + location.reload(); + return; + } + + applyStaticI18n(); syncModeUI(); showScreen('pin'); showPinError(''); - // 自动聚焦 PIN(部分移动端会被策略拦截,忽略失败) - setTimeout(function () { try { pinInput.focus(); } catch (e) {} }, 200); + // 上次成功的配对码 → 自动填充并重连,刷新/重开页面免再输一次 + var saved = readPin(); + if (saved) { + pinInput.value = saved; + doConnect(); + } else { + // 自动聚焦 PIN(部分移动端会被策略拦截,忽略失败) + setTimeout(function () { try { pinInput.focus(); } catch (e) {} }, 200); + } } init(); diff --git a/openless-all/app/src-tauri/src/remote_server/assets/icon.png b/openless-all/app/src-tauri/src/remote_server/assets/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..358c3b3b207fb331447b929f9378ff49196fb452 GIT binary patch literal 9820 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 G0{#~aLb@aX literal 0 HcmV?d00001 diff --git a/openless-all/app/src-tauri/src/remote_server/assets/index.html b/openless-all/app/src-tauri/src/remote_server/assets/index.html index d4eb4a56..0424cf81 100644 --- a/openless-all/app/src-tauri/src/remote_server/assets/index.html +++ b/openless-all/app/src-tauri/src/remote_server/assets/index.html @@ -1,25 +1,26 @@ - + - OpenLess 远程输入 + OpenLess +
- -

OpenLess 远程输入

-

在手机上录音,实时输入到电脑

+ OpenLess +

OpenLess 远程输入

+

在手机上录音,实时输入到电脑

- + OpenLess 远程输入 pattern="[0-9]*" placeholder="••••••" /> - + +
+ + +
+
连不上?多半是手机没信任证书
+

① 安卓 / 一般情况:用浏览器无痕模式打开本页,出现“不安全”警告时选“继续前往”,再输入配对码连接。

+

② iOS Safari 必须装证书:点下方按钮下载并安装描述文件,然后到「设置 → 通用 → 关于本机 → 证书信任设置」里打开它的“完全信任”,再回来连接。

+
+ ⬇ 下载并安装证书 + +
- + OpenLess OpenLess
- - + +
@@ -68,20 +80,13 @@

OpenLess 远程输入

📵
-

连接已断开

+

连接已断开

与电脑的连接已中断。

- +
- -
- 首次访问浏览器会提示“连接不安全”(本地自签名证书)。 - Android Chrome:点“高级”→“继续前往”; - iOS Safari:点“显示详情”→“访问此网站”。 -
- diff --git a/openless-all/app/src-tauri/src/remote_server/assets/style.css b/openless-all/app/src-tauri/src/remote_server/assets/style.css index d80b9c7d..92265dbb 100644 --- a/openless-all/app/src-tauri/src/remote_server/assets/style.css +++ b/openless-all/app/src-tauri/src/remote_server/assets/style.css @@ -1,20 +1,48 @@ -/* ===== OpenLess 远程输入 — 移动端深色样式 ===== */ +/* ===== OpenLess 远程输入 — 移动端样式 ===== + * 配色 / 圆角 / 阴影 / 字体对齐 PC 端 design tokens(src/styles/tokens.css): + * 黑 + 白 + 电光蓝,浅色 glassy 风格(与桌面端保持一致)。 + */ :root { - --bg: #0f1115; - --bg-soft: #171a21; - --card: #1c2029; - --card-border: #2a2f3a; - --text: #e7eaf0; - --text-dim: #9aa3b2; - --text-faint: #6b7280; - --accent: #4f8cff; - --accent-strong: #3b78f0; - --danger: #ff5470; - --danger-soft: #e0455f; - --ok: #34d399; - --warn: #fbbf24; - --shadow: 0 10px 30px rgba(0, 0, 0, .45); + /* 中性色 */ + --bg: #f7f7f8; + --surface: #ffffff; + --surface-2: #fafafa; + --line: rgba(0, 0, 0, 0.08); + --line-strong: rgba(0, 0, 0, 0.14); + + /* 墨色文字 */ + --ink: #0a0a0b; + --ink-2: #2a2a2d; + --ink-3: rgba(10, 10, 11, 0.62); + --ink-4: rgba(10, 10, 11, 0.42); + + /* 蓝色强调 */ + --blue: #2563eb; + --blue-hover: #1d4ed8; + --blue-soft: #eff4ff; + --blue-ring: rgba(37, 99, 235, 0.22); + + /* 状态色 */ + --ok: #16a34a; + --ok-soft: #ecfdf5; + --warn: #d97706; + --danger: #dc2626; + + /* 阴影 */ + --shadow-sm: 0 1px 2px rgba(15, 17, 22, 0.04), 0 0 0 0.5px rgba(0, 0, 0, 0.04); + --shadow-md: 0 1px 2px rgba(15, 17, 22, 0.05), 0 6px 24px -12px rgba(15, 17, 22, 0.10), 0 0 0 0.5px rgba(0, 0, 0, 0.04); + --shadow-lg: 0 20px 60px -20px rgba(15, 17, 22, 0.18), 0 8px 32px -16px rgba(15, 17, 22, 0.10), 0 0 0 0.5px rgba(0, 0, 0, 0.06); + + /* 圆角 */ + --r-lg: 14px; + --r-xl: 18px; + --r-2xl: 22px; + --r-pill: 999px; + + /* 字体 */ + --font-sans: system-ui, -apple-system, "PingFang SC", "Microsoft YaHei", Roboto, Helvetica, Arial, sans-serif; + --safe-bottom: env(safe-area-inset-bottom, 0px); } @@ -31,14 +59,15 @@ html, body { body { background: - radial-gradient(120% 80% at 50% -10%, #1a2030 0%, var(--bg) 60%) fixed, + radial-gradient(120% 80% at 50% -10%, #eef2fb 0%, var(--bg) 55%) fixed, var(--bg); - color: var(--text); - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", - "Hiragino Sans GB", "Microsoft YaHei", Roboto, Helvetica, Arial, sans-serif; + color: var(--ink); + font-family: var(--font-sans); font-size: 16px; line-height: 1.5; -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + font-feature-settings: 'cv11', 'ss01', 'ss03'; user-select: none; -webkit-user-select: none; overscroll-behavior: none; @@ -48,8 +77,7 @@ body { min-height: 100%; display: flex; flex-direction: column; - /* 给底部证书提示留出空间 */ - padding-bottom: calc(96px + var(--safe-bottom)); + padding-bottom: calc(24px + var(--safe-bottom)); } /* ===== 屏幕切换 ===== */ @@ -74,29 +102,33 @@ body { text-align: center; margin: 28px 0 22px; } -.brand-logo { - font-size: 56px; - line-height: 1; +.brand-logo-img { + width: 72px; + height: 72px; + border-radius: var(--r-xl); + object-fit: cover; + box-shadow: var(--shadow-lg); } .brand-title { font-size: 22px; font-weight: 700; - margin: 14px 0 4px; - letter-spacing: .3px; + margin: 16px 0 4px; + letter-spacing: .2px; + color: var(--ink); } .brand-sub { margin: 0; - color: var(--text-dim); + color: var(--ink-3); font-size: 14px; } /* ===== 卡片 ===== */ .card { - background: var(--card); - border: 1px solid var(--card-border); - border-radius: 18px; + background: var(--surface); + border: 0.5px solid var(--line); + border-radius: var(--r-2xl); padding: 22px 20px; - box-shadow: var(--shadow); + box-shadow: var(--shadow-lg); } .card-center { text-align: center; @@ -105,7 +137,7 @@ body { .field-label { display: block; font-size: 13px; - color: var(--text-dim); + color: var(--ink-3); margin-bottom: 10px; } @@ -116,20 +148,21 @@ body { letter-spacing: 14px; text-align: center; padding: 16px 12px; - color: var(--text); - background: var(--bg-soft); - border: 2px solid var(--card-border); - border-radius: 14px; + color: var(--ink); + background: var(--surface-2); + border: 1.5px solid var(--line-strong); + border-radius: var(--r-lg); outline: none; font-variant-numeric: tabular-nums; - transition: border-color .15s ease; + transition: border-color .15s ease, box-shadow .15s ease; } .pin-input::placeholder { - color: var(--text-faint); + color: var(--ink-4); letter-spacing: 14px; } .pin-input:focus { - border-color: var(--accent); + border-color: var(--blue); + box-shadow: 0 0 0 3px var(--blue-ring); } /* ===== 按钮 ===== */ @@ -144,17 +177,18 @@ body { font-weight: 600; color: #fff; border: none; - border-radius: 14px; + border-radius: var(--r-lg); cursor: pointer; transition: transform .08s ease, background .15s ease, opacity .15s ease; } .btn:active { transform: scale(.98); } -.btn:disabled { opacity: .55; cursor: default; } +.btn:disabled { opacity: .5; cursor: default; } .btn-primary { - background: linear-gradient(180deg, var(--accent) 0%, var(--accent-strong) 100%); - box-shadow: 0 6px 18px rgba(59, 120, 240, .35); + background: var(--blue); + box-shadow: 0 6px 18px -6px var(--blue-ring); } +.btn-primary:active { background: var(--blue-hover); } .hint-error { color: var(--danger); @@ -163,6 +197,55 @@ body { min-height: 1em; } +/* ===== 连接帮助(配对屏) ===== */ +.help { + margin-top: 18px; + padding: 16px 16px 18px; + border-radius: var(--r-xl); + background: var(--surface-2); + border: 0.5px solid var(--line); +} +.help-title { + font-size: 13.5px; + font-weight: 600; + color: var(--ink-2); + margin-bottom: 10px; +} +.help-step { + font-size: 12.5px; + color: var(--ink-3); + line-height: 1.65; + margin: 0 0 9px; +} +.help-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 6px; +} +.help-link { + display: inline-block; + padding: 9px 16px; + border-radius: var(--r-md); + border: none; + background: var(--blue); + color: #fff; + font-size: 13px; + font-weight: 600; + font-family: inherit; + text-decoration: none; + cursor: pointer; + -webkit-appearance: none; + appearance: none; +} +.help-link:active { background: var(--blue-hover); } +.help-link-ghost { + background: var(--surface); + color: var(--blue); + border: 1px solid var(--blue); +} +.help-link-ghost:active { background: var(--blue-soft); } + /* ===== 录音屏头部 ===== */ .rec-header { display: flex; @@ -170,24 +253,24 @@ body { gap: 10px; padding-bottom: 8px; } -.conn-dot { - width: 10px; - height: 10px; - border-radius: 50%; - background: var(--ok); - box-shadow: 0 0 0 3px rgba(52, 211, 153, .18); +.app-icon { + width: 26px; + height: 26px; + border-radius: 7px; flex: none; + box-shadow: var(--shadow-sm); } .rec-header-title { font-weight: 700; font-size: 16px; - letter-spacing: .4px; + letter-spacing: .2px; + color: var(--ink); } .mode-switch { margin-left: auto; display: inline-flex; - background: var(--bg-soft); - border: 1px solid var(--card-border); + background: var(--surface-2); + border: 0.5px solid var(--line); border-radius: 12px; padding: 3px; gap: 2px; @@ -197,7 +280,7 @@ body { appearance: none; border: none; background: transparent; - color: var(--text-dim); + color: var(--ink-3); font-size: 13px; font-weight: 600; padding: 7px 14px; @@ -206,7 +289,7 @@ body { transition: background .15s ease, color .15s ease; } .mode-btn.active { - background: var(--accent); + background: var(--blue); color: #fff; } @@ -220,7 +303,7 @@ body { gap: 26px; } -/* 录音大按钮 */ +/* 录音大按钮 —— 默认蓝色实心(对齐 PC 主操作蓝),录音中转红 */ .record-btn { position: relative; width: 168px; @@ -229,10 +312,10 @@ body { border: none; cursor: pointer; color: #fff; - background: radial-gradient(circle at 50% 38%, #2a3550 0%, #1b2136 100%); + background: linear-gradient(180deg, #3b82f6 0%, #2563eb 100%); box-shadow: - 0 12px 34px rgba(0, 0, 0, .5), - inset 0 0 0 2px rgba(255, 255, 255, .04); + 0 16px 36px -10px rgba(37, 99, 235, .5), + inset 0 1px 0 rgba(255, 255, 255, .25); display: flex; flex-direction: column; align-items: center; @@ -248,7 +331,7 @@ body { position: absolute; inset: -6px; border-radius: 50%; - border: 2px solid rgba(79, 140, 255, .35); + border: 2px solid rgba(37, 99, 235, .35); opacity: 0; pointer-events: none; } @@ -260,19 +343,20 @@ body { .record-btn-label { font-size: 14px; font-weight: 600; - color: var(--text-dim); - letter-spacing: .5px; + color: rgba(255, 255, 255, .92); + letter-spacing: .3px; } /* 录音中:红色 + 呼吸脉冲动画 */ .record-btn.recording { - background: radial-gradient(circle at 50% 38%, #ff6a82 0%, #d63a55 100%); - box-shadow: 0 12px 34px rgba(214, 58, 85, .5); + background: linear-gradient(180deg, #f87171 0%, #dc2626 100%); + box-shadow: 0 16px 36px -10px rgba(220, 38, 38, .5); animation: breathe 1.6s ease-in-out infinite; } .record-btn.recording .record-btn-label { color: #fff; } .record-btn.recording .record-btn-ring { opacity: 1; + border-color: rgba(220, 38, 38, .4); animation: pulseRing 1.6s ease-out infinite; } @@ -288,7 +372,7 @@ body { /* 忙/禁用态 */ .record-btn.busy { - opacity: .6; + opacity: .5; cursor: default; animation: none; } @@ -298,16 +382,22 @@ body { width: 78%; max-width: 320px; height: 8px; - border-radius: 99px; - background: var(--bg-soft); - border: 1px solid var(--card-border); + border-radius: var(--r-pill); + background: #e9ebf0; + box-shadow: inset 0 1px 2px rgba(0, 0, 0, .07); overflow: hidden; + /* 平时淡化成一个浅凹槽;录音时才高亮(下方规则),避免像一根无意义的白条 */ + opacity: .45; + transition: opacity .2s ease; +} +.record-btn.recording ~ .level-wrap { + opacity: 1; } .level-bar { height: 100%; width: 0%; - border-radius: 99px; - background: linear-gradient(90deg, var(--ok), var(--accent)); + border-radius: var(--r-pill); + background: linear-gradient(90deg, var(--ok), var(--blue)); transition: width .08s linear; } @@ -315,31 +405,32 @@ body { .status-bar { min-height: 28px; padding: 8px 18px; - border-radius: 99px; - background: var(--bg-soft); - border: 1px solid var(--card-border); + border-radius: var(--r-pill); + background: var(--surface); + border: 0.5px solid var(--line); + box-shadow: var(--shadow-sm); font-size: 15px; font-weight: 600; - color: var(--text); + color: var(--ink); text-align: center; max-width: 90%; } -.status-bar.is-error { color: var(--danger); border-color: rgba(255, 84, 112, .4); } -.status-bar.is-ok { color: var(--ok); border-color: rgba(52, 211, 153, .4); } -.status-bar.is-work { color: var(--accent); } +.status-bar.is-error { color: var(--danger); border-color: rgba(220, 38, 38, .35); } +.status-bar.is-ok { color: var(--ok); border-color: rgba(22, 163, 74, .35); } +.status-bar.is-work { color: var(--blue); border-color: var(--blue-ring); } /* ===== 提示文字 ===== */ .rec-tip { text-align: center; - color: var(--text-faint); + color: var(--ink-4); font-size: 13px; margin: 18px 0 0; } /* ===== 断线屏 ===== */ .offline-icon { font-size: 48px; } -.offline-title { font-size: 20px; margin: 12px 0 6px; } -.offline-sub { color: var(--text-dim); font-size: 14px; margin: 0 0 8px; } +.offline-title { font-size: 20px; margin: 12px 0 6px; color: var(--ink); } +.offline-sub { color: var(--ink-3); font-size: 14px; margin: 0 0 8px; } /* ===== 底部证书提示(固定) ===== */ .cert-tip { @@ -350,18 +441,18 @@ body { padding: 12px 16px calc(12px + var(--safe-bottom)); font-size: 12px; line-height: 1.5; - color: var(--text-faint); - background: rgba(15, 17, 21, .92); - backdrop-filter: blur(6px); - -webkit-backdrop-filter: blur(6px); - border-top: 1px solid var(--card-border); + color: var(--ink-4); + background: rgba(255, 255, 255, .85); + backdrop-filter: blur(12px) saturate(160%); + -webkit-backdrop-filter: blur(12px) saturate(160%); + border-top: 0.5px solid var(--line); text-align: center; } /* 小屏微调 */ @media (max-height: 640px) { .brand { margin: 14px 0; } - .brand-logo { font-size: 44px; } + .brand-logo-img { width: 56px; height: 56px; } .record-btn { width: 148px; height: 148px; } .record-btn-icon { font-size: 44px; } } diff --git a/openless-all/app/src-tauri/src/remote_server/mod.rs b/openless-all/app/src-tauri/src/remote_server/mod.rs index f286c3d2..46b49e31 100644 --- a/openless-all/app/src-tauri/src/remote_server/mod.rs +++ b/openless-all/app/src-tauri/src/remote_server/mod.rs @@ -24,7 +24,7 @@ use axum::{ use hyper_util::rt::{TokioExecutor, TokioIo}; use parking_lot::Mutex; use serde::Serialize; -use tauri::{AppHandle, Listener}; +use tauri::{AppHandle, Listener, Manager}; use tokio::net::TcpListener; use tokio_rustls::TlsAcceptor; @@ -34,6 +34,7 @@ mod assets { pub const INDEX_HTML: &str = include_str!("assets/index.html"); pub const APP_JS: &str = include_str!("assets/app.js"); pub const STYLE_CSS: &str = include_str!("assets/style.css"); + pub const ICON_PNG: &[u8] = include_bytes!("assets/icon.png"); } const HEADER_HTML: &str = "text/html; charset=utf-8"; @@ -90,6 +91,39 @@ pub fn generate_pin() -> String { format!("{n:06}") } +fn pin_path(app: &AppHandle) -> Option { + app.path() + .app_config_dir() + .ok() + .map(|d| d.join("remote-input-pin.txt")) +} + +/// 读持久化的配对码;没有 / 无效则新生成并写盘。让配对码跨重启稳定 —— 否则每次启动 +/// 都重新随机一个,用户得反复回来找新码("配对码错误"的根因)。 +pub fn load_or_create_pin(app: &AppHandle) -> String { + if let Some(p) = pin_path(app) { + if let Ok(s) = std::fs::read_to_string(&p) { + let s = s.trim(); + if s.len() == 6 && s.bytes().all(|b| b.is_ascii_digit()) { + return s.to_string(); + } + } + } + let pin = generate_pin(); + save_pin(app, &pin); + pin +} + +/// 写配对码到磁盘(用户点"重置配对码"时覆盖)。 +pub fn save_pin(app: &AppHandle, pin: &str) { + if let Some(p) = pin_path(app) { + if let Some(dir) = p.parent() { + let _ = std::fs::create_dir_all(dir); + } + let _ = std::fs::write(&p, pin); + } +} + fn is_private_lan(ip: &Ipv4Addr) -> bool { let o = ip.octets(); !ip.is_loopback() @@ -126,23 +160,86 @@ pub fn access_urls(port: u16) -> Vec { // ───────────────────────── TLS ───────────────────────── -fn build_rustls_config() -> Result, String> { - let mut sans = vec!["localhost".to_string(), "127.0.0.1".to_string()]; - for ip in local_lan_ipv4s() { - sans.push(ip.to_string()); +/// 自签名证书:持久化到磁盘并跨重启复用。否则每次启动证书都变 —— 手机(尤其 iOS +/// Safari)上一次信任过的证书立刻失效,wss 握手静默挂起,表现为"连接中"卡死。仅当 +/// 磁盘无证书 / 解析失败 / 当前局域网 IP 不在已存 SAN 列表里(换了网络)时才重新生成。 +/// 返回 (证书 DER 原始字节, 私钥)。 +fn load_or_generate_cert( + dir: Option<&std::path::Path>, + sans: &[String], +) -> Result<(Vec, rustls::pki_types::PrivateKeyDer<'static>), String> { + use rustls::pki_types::{PrivateKeyDer, PrivatePkcs8KeyDer}; + // 文件名带 schema 版本:证书结构变更(v4 改回非 CA 服务器证书)时旧文件自动失效、重新生成。 + const CERT_FILE: &str = "remote-cert-v4.der"; + const KEY_FILE: &str = "remote-key-v4.der"; + const SANS_FILE: &str = "remote-cert-sans-v4.txt"; + if let Some(dir) = dir { + if let (Ok(cert), Ok(key), Ok(saved)) = ( + std::fs::read(dir.join(CERT_FILE)), + std::fs::read(dir.join(KEY_FILE)), + std::fs::read_to_string(dir.join(SANS_FILE)), + ) { + let saved_set: std::collections::HashSet<&str> = saved.lines().collect(); + // 当前需要的 SAN 都在已存证书里 → 复用,证书保持稳定(手机信任一次长期有效)。 + if sans.iter().all(|s| saved_set.contains(s.as_str())) { + log::info!("[remote-input] reusing persisted self-signed server cert"); + return Ok((cert, PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(key)))); + } + } } - let certified = - rcgen::generate_simple_self_signed(sans).map_err(|e| format!("rcgen: {e}"))?; - let cert_der = certified.cert.der().clone(); - let key_der = rustls::pki_types::PrivatePkcs8KeyDer::from( - certified.key_pair.serialize_der(), - ); + // 生成自签名 CA 证书:iOS Safari / Android Chrome 的 wss 都不复用浏览器页面级的 + // “继续访问”例外,必须把证书装进系统并信任 —— 而系统的信任开关只对 CA 证书出现。 + // 所以做成自签名 CA(SAN 含本机各局域网 IP),用户在手机装一次并信任后 wss 才稳定。 + let (cert_der, key_der) = { + use rcgen::{ + CertificateParams, DistinguishedName, DnType, ExtendedKeyUsagePurpose, KeyPair, + KeyUsagePurpose, + }; + let mut params = + CertificateParams::new(sans.to_vec()).map_err(|e| format!("rcgen params: {e}"))?; + // 关键:做成普通服务器证书(非 CA,rcgen 默认即 NoCa)。iOS Safari 用页面级 + // “访问此网站”即可信任、无需安装证书 —— 这正是之前一直能用的方式。把证书做成 CA + // 反而会让 iOS 拒绝页面级例外(CA 不能直接当服务器证书),导致一直超时。 + let mut dn = DistinguishedName::new(); + dn.push(DnType::CommonName, "OpenLess Remote Input"); + dn.push(DnType::OrganizationName, "OpenLess"); + params.distinguished_name = dn; + params.key_usages.push(KeyUsagePurpose::DigitalSignature); + params + .extended_key_usages + .push(ExtendedKeyUsagePurpose::ServerAuth); + let key_pair = KeyPair::generate().map_err(|e| format!("rcgen keypair: {e}"))?; + let cert = params + .self_signed(&key_pair) + .map_err(|e| format!("rcgen self_signed: {e}"))?; + (cert.der().as_ref().to_vec(), key_pair.serialize_der()) + }; + if let Some(dir) = dir { + let _ = std::fs::create_dir_all(dir); + let _ = std::fs::write(dir.join(CERT_FILE), &cert_der); + let _ = std::fs::write(dir.join(KEY_FILE), &key_der); + let _ = std::fs::write(dir.join(SANS_FILE), sans.join("\n")); + log::info!("[remote-input] generated new self-signed server cert (SAN={sans:?})"); + } + Ok(( + cert_der, + PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(key_der)), + )) +} + +fn build_server_config( + cert_der: Vec, + key_der: rustls::pki_types::PrivateKeyDer<'static>, +) -> Result, String> { let provider = Arc::new(rustls::crypto::ring::default_provider()); let config = rustls::ServerConfig::builder_with_provider(provider) .with_safe_default_protocol_versions() .map_err(|e| format!("tls protocol: {e}"))? .with_no_client_auth() - .with_single_cert(vec![cert_der], key_der.into()) + .with_single_cert( + vec![rustls::pki_types::CertificateDer::from(cert_der)], + key_der, + ) .map_err(|e| format!("tls cert: {e}"))?; Ok(Arc::new(config)) } @@ -155,11 +252,13 @@ struct WsState { app: AppHandle, /// 全局 PIN 失败计数 + 锁定截止时刻(简单防爆破;TLS+6 位 PIN 已是主防线)。 pin_fails: Mutex<(u32, Option)>, + /// 自签名证书的 DER 原始字节,供 /cert.cer 下载给手机安装信任。 + cert_der: Vec, } fn build_router(state: Arc) -> Router { Router::new() - .route("/", get(|| async { Html(assets::INDEX_HTML) })) + .route("/", get(index_handler)) .route( "/app.js", get(|| async { ([(axum::http::header::CONTENT_TYPE, HEADER_JS)], assets::APP_JS) }), @@ -170,13 +269,89 @@ fn build_router(state: Arc) -> Router { ([(axum::http::header::CONTENT_TYPE, HEADER_CSS)], assets::STYLE_CSS) }), ) + .route( + "/icon.png", + get(|| async { + ([(axum::http::header::CONTENT_TYPE, "image/png")], assets::ICON_PNG) + }), + ) + // 证书下载:手机在浏览器打开它即可下载并安装信任(iOS Safari 的 wss 不复用 + // 页面级证书例外,需在系统里完全信任后 wss 才稳定)。 + .route( + "/cert.cer", + get(|State(state): State>| async move { + ( + [(axum::http::header::CONTENT_TYPE, "application/x-x509-ca-cert")], + state.cert_der.clone(), + ) + }), + ) + .route("/cert.mobileconfig", get(mobileconfig_handler)) .route("/ws", get(ws_upgrade)) .with_state(state) } +/// 首页:按 PC 端当前界面语言把 `__OL_LANG__` 占位替换成实际 locale, +/// H5 据此(window.__OL_LANG__ / )选择显示语言。 +async fn index_handler(State(state): State>) -> impl IntoResponse { + let lang = state.coordinator.remote_locale(); + Html(assets::INDEX_HTML.replace("%%OL_LANG%%", &lang)) +} + +/// 极简标准 base64:构造 .mobileconfig 时把证书 DER 编码进 XML,避免引入额外依赖。 +fn base64_encode(data: &[u8]) -> String { + const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = String::with_capacity((data.len() + 2) / 3 * 4); + for chunk in data.chunks(3) { + let b0 = chunk[0]; + let b1 = *chunk.get(1).unwrap_or(&0); + let b2 = *chunk.get(2).unwrap_or(&0); + out.push(T[(b0 >> 2) as usize] as char); + out.push(T[(((b0 & 0x03) << 4) | (b1 >> 4)) as usize] as char); + out.push(if chunk.len() > 1 { + T[(((b1 & 0x0f) << 2) | (b2 >> 6)) as usize] as char + } else { + '=' + }); + out.push(if chunk.len() > 2 { + T[(b2 & 0x3f) as usize] as char + } else { + '=' + }); + } + out +} + +/// iOS 配置描述文件:把证书包成 .mobileconfig。Safari 点击后凭 content-type +/// (application/x-apple-aspen-config) 直接进入“安装描述文件”流程,比裸 .cer 顺滑、 +/// 也不会把当前页面导航走。安装后仍需到「设置→通用→关于本机→证书信任设置」打开完全信任。 +async fn mobileconfig_handler(State(state): State>) -> impl IntoResponse { + let b64 = base64_encode(&state.cert_der); + let xml = format!( + r#" + +PayloadContentPayloadCertificateFileNameopenless.cerPayloadContent{b64}PayloadTypecom.apple.security.rootPayloadIdentifiercom.openless.remote-input.certPayloadUUIDA1B2C3D4-0001-4000-8000-000000000001PayloadVersion1PayloadDisplayNameOpenLess Remote Input CertificatePayloadDisplayNameOpenLess Remote InputPayloadIdentifiercom.openless.remote-inputPayloadTypeConfigurationPayloadUUIDA1B2C3D4-0002-4000-8000-000000000002PayloadVersion1"#, + b64 = b64 + ); + ( + [( + axum::http::header::CONTENT_TYPE, + "application/x-apple-aspen-config", + )], + xml, + ) +} + pub async fn start(cfg: RemoteServerConfig) -> Result { let _ = HEADER_HTML; // index 用 axum Html() 自带 content-type - let rustls_config = build_rustls_config()?; + let mut sans = vec!["localhost".to_string(), "127.0.0.1".to_string()]; + for ip in local_lan_ipv4s() { + sans.push(ip.to_string()); + } + // 证书目录用 app 配置目录(跨重启稳定);拿不到则退回内存生成(不持久化)。 + let cert_dir = cfg.app.path().app_config_dir().ok(); + let (cert_der, key_der) = load_or_generate_cert(cert_dir.as_deref(), &sans)?; + let rustls_config = build_server_config(cert_der.clone(), key_der)?; let acceptor = TlsAcceptor::from(rustls_config); let addr = SocketAddr::from(([0, 0, 0, 0], cfg.port)); @@ -194,6 +369,7 @@ pub async fn start(cfg: RemoteServerConfig) -> Result Result { - let (tcp, _peer) = match accepted { + let (tcp, peer) = match accepted { Ok(x) => x, Err(e) => { log::warn!("[remote-input] accept error: {e}"); continue; } }; + // 最底层诊断:每个到达本机 8443 的 TCP 连接都记下来源 IP。手机一连就能 + // 看到它到底有没有真的到这台电脑、来自哪个网段(排查"是不是连到别的设备")。 + log::info!("[remote-input] 收到 TCP 连接,来自 {peer}"); let acceptor = acceptor.clone(); let router = router.clone(); tokio::spawn(async move { let tls = match acceptor.accept(tcp).await { Ok(t) => t, - Err(_) => return, // 客户端没装证书 / 握手失败:静默 + Err(e) => { + log::warn!("[remote-input] 来自 {peer} 的 TLS 握手失败(证书没被接受):{e}"); + return; + } }; let io = TokioIo::new(tls); let svc = hyper_util::service::TowerToHyperService::new(router); @@ -245,6 +427,9 @@ async fn ws_upgrade( State(state): State>, ws: WebSocketUpgrade, ) -> impl IntoResponse { + // 能走到这里说明 wss 的 TLS 握手已成功(证书被手机接受)。排查"连不上"时看有没有 + // 这行:没有 = 卡在 TLS/证书(握手就失败);有 = 握手 OK,问题在认证/后续逻辑。 + log::info!("[remote-input] WS 已升级:手机已通过 wss 接入(TLS/证书 OK)"); ws.on_upgrade(move |socket| handle_ws(socket, state)) } @@ -301,15 +486,18 @@ async fn handle_ws(mut socket: WebSocket, state: Arc) { }; match authed { AuthResult::Ok => { + log::info!("[remote-input] 配对成功,进入录音会话"); let _ = socket.send(send_json(&serde_json::json!({"type":"auth","ok":true}))).await; } AuthResult::BadPin => { + log::warn!("[remote-input] 配对码错误,已拒绝"); let _ = socket .send(send_json(&serde_json::json!({"type":"auth","ok":false,"reason":"bad-pin"}))) .await; return; } AuthResult::Locked => { + log::warn!("[remote-input] 配对已锁定(连续错误过多),已拒绝"); let _ = socket .send(send_json(&serde_json::json!({"type":"auth","ok":false,"reason":"locked"}))) .await; @@ -321,7 +509,11 @@ async fn handle_ws(mut socket: WebSocket, state: Arc) { let (evt_tx, mut evt_rx) = tokio::sync::mpsc::unbounded_channel::(); let listener_id = { let tx = evt_tx.clone(); - state.app.listen("capsule:state", move |event| { + // 必须用 listen_any:capsule 状态是通过 emit_to("capsule", …) 定向发给胶囊 + // 窗口的,普通 app.listen(target=App) 收不到定向事件 —— 那样手机永远收不到 + // done/polishing 等状态,会一直卡在前端本地设的"识别中"。listen_any 接收 + // 所有 target 的事件,把胶囊状态如实转发给手机。 + state.app.listen_any("capsule:state", move |event| { for msg in capsule_payload_to_phone(event.payload()) { let _ = tx.send(msg); } @@ -356,6 +548,7 @@ async fn handle_ws(mut socket: WebSocket, state: Arc) { } // 4) 收尾:断连即取消未完成的远程会话,避免 ASR 句柄悬挂。 + log::info!("[remote-input] WS 连接已关闭"); state.app.unlisten(listener_id); state.coordinator.cancel_remote_dictation(); } @@ -367,15 +560,20 @@ async fn handle_control(txt: &str, state: &Arc, socket: &mut WebSocket) Err(_) => return true, }; match v.get("type").and_then(|t| t.as_str()).unwrap_or("") { - "start" => match state.coordinator.start_remote_dictation().await { - Ok(()) => {} - Err(reason) => { - let _ = socket - .send(send_json(&serde_json::json!({"type":"busy","reason":reason}))) - .await; + "start" => { + log::info!("[remote-input] 收到「开始录音」"); + match state.coordinator.start_remote_dictation().await { + Ok(()) => {} + Err(reason) => { + log::warn!("[remote-input] 开始录音被拒:{reason}"); + let _ = socket + .send(send_json(&serde_json::json!({"type":"busy","reason":reason}))) + .await; + } } - }, + } "stop" => { + log::info!("[remote-input] 收到「结束录音」"); let _ = state.coordinator.stop_remote_dictation().await; } "cancel" => { diff --git a/openless-all/app/src/i18n/index.ts b/openless-all/app/src/i18n/index.ts index 7a2b6ef7..93ba05b6 100644 --- a/openless-all/app/src/i18n/index.ts +++ b/openless-all/app/src/i18n/index.ts @@ -16,6 +16,7 @@ import { ko } from './ko'; import { zhCN } from './zh-CN'; import { zhTW } from './zh-TW'; import type { UserPreferences } from '../lib/types'; +import { setRemoteLocale } from '../lib/ipc'; export const SUPPORTED_LOCALES = ['zh-CN', 'zh-TW', 'en', 'ja', 'ko'] as const; export type SupportedLocale = (typeof SUPPORTED_LOCALES)[number]; @@ -88,9 +89,18 @@ export async function setLocalePreference( window.localStorage.setItem(LOCALE_STORAGE_KEY, pref); } await i18n.changeLanguage(resolved); + syncRemoteLocale(resolved); return resolved; } +// 远程输入 H5 录音页跟随 PC 界面语言:把已解析的 locale 推给后端(后端只存内存 +// 镜像,H5 请求首页时据此渲染)。非 Tauri(浏览器 dev)环境走 mock no-op,失败静默。 +function syncRemoteLocale(resolved: SupportedLocale): void { + void setRemoteLocale(resolved).catch(() => {}); +} +// 启动时同步一次当前语言,覆盖“开机即自动开启远程服务”的场景。 +syncRemoteLocale(i18n.language as SupportedLocale); + export const FOLLOW_SYSTEM = FOLLOW_SYSTEM_VALUE; export function outputPrefsForLocale( diff --git a/openless-all/app/src/lib/ipc.ts b/openless-all/app/src/lib/ipc.ts index 30719c54..4ef570d2 100644 --- a/openless-all/app/src/lib/ipc.ts +++ b/openless-all/app/src/lib/ipc.ts @@ -553,6 +553,11 @@ export function regenerateRemotePin(): Promise { return invokeOrMock("regenerate_remote_pin", undefined, () => "123456") } +/** 把 PC 端界面语言同步给远程输入服务,H5 录音页据此显示对应语言。 */ +export function setRemoteLocale(locale: string): Promise { + return invokeOrMock("set_remote_locale", { locale }, () => undefined) +} + // ── Release channel (Beta opt-in) ────────────────────────────────────── // 渠道偏好与 fetch_latest_beta_release 实际效果只在 Tauri runtime 内有意义; // 浏览器开发模式下走 mock,避免设置页因 invoke 抛错而白屏。 diff --git a/openless-all/app/src/pages/settings/RemoteInputSection.tsx b/openless-all/app/src/pages/settings/RemoteInputSection.tsx index 342e4492..b549032f 100644 --- a/openless-all/app/src/pages/settings/RemoteInputSection.tsx +++ b/openless-all/app/src/pages/settings/RemoteInputSection.tsx @@ -11,6 +11,7 @@ import { SettingRow, Toggle, inputStyle } from './shared'; import { getRemoteInputStatus, regenerateRemotePin, + setRemoteLocale, isTauri, type RemoteInputStatus, } from '../../lib/ipc'; @@ -37,7 +38,7 @@ async function copyText(text: string): Promise { } export function RemoteInputSection() { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const { prefs, updatePrefs } = useHotkeySettings(); const [status, setStatus] = useState(null); const [errorPort, setErrorPort] = useState(null); @@ -50,6 +51,8 @@ export function RemoteInputSection() { .then((s) => alive && setStatus(s)) .catch(() => {}); refresh(); + // 进设置页时把当前界面语言同步给远程服务,确保 H5 录音页语言与 PC 一致。 + void setRemoteLocale(i18n.language).catch(() => {}); if (!isTauri) return; const unsubs: Array<() => void> = []; import('@tauri-apps/api/event').then(({ listen }) => { From 0071c292a64d88709d163c2ef7bde6947fafc9b3 Mon Sep 17 00:00:00 2001 From: ciddwd <572242998@qq.com> Date: Sun, 7 Jun 2026 21:38:08 +0800 Subject: [PATCH 3/8] =?UTF-8?q?feat(remote-input):=20=E6=96=87=E5=AD=97?= =?UTF-8?q?=E5=9B=9E=E4=BC=A0/=E4=B8=80=E9=94=AE=E5=A4=8D=E5=88=B6/?= =?UTF-8?q?=E7=94=B5=E8=84=91=E8=90=BD=E5=AD=97=E5=BC=80=E5=85=B3=20+=20H5?= =?UTF-8?q?=20=E5=9B=BE=E6=A0=87=E4=BA=A4=E4=BA=92=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 文字回传:电脑落字完成后把最终文字回传到手机 H5;一键复制(navigator.clipboard 优先 + execCommand 兜底,高兼容) - 电脑落字开关:关闭则只回传文字、不落到电脑光标(远程会话禁流式 + 跳过一次性插入) - 图标:录音按钮换 mic.png、完成换 done.png、识别中改三点加载动效 - 隐藏配对屏「下载证书」帮助块 - 修复 [hidden] 被元素自带 display 覆盖导致空结果框/三点照常显示 --- openless-all/app/src-tauri/src/coordinator.rs | 12 ++ .../src-tauri/src/coordinator/dictation.rs | 34 ++++- .../src-tauri/src/remote_server/assets/app.js | 114 ++++++++++++++- .../src/remote_server/assets/done.png | Bin 0 -> 5605 bytes .../src/remote_server/assets/index.html | 21 ++- .../src/remote_server/assets/mic.png | Bin 0 -> 4327 bytes .../src/remote_server/assets/style.css | 132 +++++++++++++++++- .../app/src-tauri/src/remote_server/mod.rs | 33 +++++ 8 files changed, 328 insertions(+), 18 deletions(-) create mode 100644 openless-all/app/src-tauri/src/remote_server/assets/done.png create mode 100644 openless-all/app/src-tauri/src/remote_server/assets/mic.png diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 49baab54..f20f9085 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -302,6 +302,9 @@ struct Inner { /// PC 端当前界面语言(BCP-47,如 "zh-CN")。前端切换语言时经命令同步, /// H5 录音页据此渲染对应语言。进程内镜像,不持久化(前端会在启动/切换时重新下发)。 remote_locale: Mutex, + /// 远程「仅回传」开关:true = 手机端关掉了「电脑落字」,本次远程听写不插入到电脑光标, + /// 只把最终文字回传给手机(见 dictation 落字处 + remote:result)。默认 false(照常落字)。 + remote_no_insert: AtomicBool, /// Less Computer 连续对话:true=浮窗里已有进行中的会话,下一轮 `claude --continue` 续上下文; /// 关闭浮窗(dismiss)复位为 false,下次说话开新会话。 less_computer_conversation: AtomicBool, @@ -382,6 +385,7 @@ impl Coordinator { remote_server: Mutex::new(None), remote_pin: Mutex::new(None), remote_locale: Mutex::new(String::from("zh-CN")), + remote_no_insert: AtomicBool::new(false), less_computer_conversation: AtomicBool::new(false), }), } @@ -455,6 +459,7 @@ impl Coordinator { remote_server: Mutex::new(None), remote_pin: Mutex::new(None), remote_locale: Mutex::new(String::from("zh-CN")), + remote_no_insert: AtomicBool::new(false), less_computer_conversation: AtomicBool::new(false), }), } @@ -1031,6 +1036,13 @@ impl Coordinator { /// 手机点"开始录音"。本地听写正在进行(phase != Idle)则拒绝并回 "busy"; /// 否则置位 remote 标志后走 begin_session(内部跳过 cpal,把 consumer 存进 sink)。 + /// 设置远程「仅回传」开关(手机端「电脑落字」开关的反值)。true = 不落字、只回传。 + pub fn set_remote_no_insert(&self, no_insert: bool) { + self.inner + .remote_no_insert + .store(no_insert, Ordering::SeqCst); + } + pub async fn start_remote_dictation(&self) -> Result<(), String> { if !matches!(self.inner.state.lock().phase, SessionPhase::Idle) { return Err("busy".into()); diff --git a/openless-all/app/src-tauri/src/coordinator/dictation.rs b/openless-all/app/src-tauri/src/coordinator/dictation.rs index 2cf767b5..32e9a55a 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation.rs @@ -2133,12 +2133,17 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { }; // 流式插入 opt-in 路径:开关打开 + 非翻译 + 非 Raw 模式 → 进入流式分支。 // 任何不满足都走原一次性 polish_or_passthrough 路径,行为跟历史完全一致。 - let streaming_eligible = streaming_insert_eligible( - prefs.streaming_insert, - translation_active, - mode, - raw_uses_llm, - ); + // 远程「仅回传」模式:手机端关掉了「电脑落字」开关 —— 禁用流式插入(否则会边润色边把字 + // 落到电脑),改走一次性路径,最后在插入处统一跳过,只把文字回传给手机。 + let remote_no_insert = inner.remote_source_active.load(Ordering::SeqCst) + && inner.remote_no_insert.load(Ordering::SeqCst); + let streaming_eligible = !remote_no_insert + && streaming_insert_eligible( + prefs.streaming_insert, + translation_active, + mode, + raw_uses_llm, + ); log::info!( "[coord] polish dispatch: translation={translation_active} mode={mode:?} streaming_eligible={streaming_eligible}" ); @@ -2247,7 +2252,14 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { let allow_non_tsf_insertion_fallback = prefs.allow_non_tsf_insertion_fallback; let paste_shortcut = prefs.paste_shortcut; // 流式路径下,字符已经通过 Unicode keystroke 落到光标处,跳过 inserter.insert。 - let status = if already_streamed { + let status = if remote_no_insert { + // 仅回传模式:不碰光标/剪贴板,电脑端无感;文字稍后经 remote:result 发给手机。 + log::info!( + "[coord] remote no-insert: skip insertion, relay {} chars to phone only", + polished.chars().count() + ); + InsertStatus::Inserted + } else if already_streamed { log::info!( "[coord] insertion skipped: {} chars already streamed via unicode_keystroke (polish_error={:?})", polished.chars().count(), @@ -2374,6 +2386,14 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { Some(inserted_chars), ); + // 远程会话:把最终文字回传给手机 H5。PC 胶囊只显示字数,但手机端用户看不到电脑 + // 屏幕,需要直接看到这次落下的文字内容(remote_server 转发为 type=result)。 + if inner.remote_source_active.load(Ordering::SeqCst) { + if let Some(app) = inner.app.lock().clone() { + let _ = app.emit("remote:result", polished.clone()); + } + } + { let mut state = inner.state.lock(); state.phase = SessionPhase::Idle; diff --git a/openless-all/app/src-tauri/src/remote_server/assets/app.js b/openless-all/app/src-tauri/src/remote_server/assets/app.js index 4db0c8bd..ab8d1497 100644 --- a/openless-all/app/src-tauri/src/remote_server/assets/app.js +++ b/openless-all/app/src-tauri/src/remote_server/assets/app.js @@ -21,6 +21,7 @@ btnConnect: '连接', btnConnecting: '连接中…', modeToggle: '点按', + insertLabel: '电脑落字', modeHold: '按住', offlineTitle: '连接已断开', offlineSub: '与电脑的连接已中断。', @@ -60,6 +61,8 @@ helpDownloadCert: '⬇ 下载并安装证书', helpCopyLink: '⧉ 复制链接', helpCopied: '已复制 ✓', + copy: '复制', + copied: '已复制 ✓', }, 'zh-TW': { title: 'OpenLess 遠端輸入', @@ -69,6 +72,7 @@ btnConnect: '連線', btnConnecting: '連線中…', modeToggle: '點按', + insertLabel: '電腦落字', modeHold: '按住', offlineTitle: '連線已中斷', offlineSub: '與電腦的連線已中斷。', @@ -108,6 +112,8 @@ helpDownloadCert: '⬇ 下載並安裝憑證', helpCopyLink: '⧉ 複製連結', helpCopied: '已複製 ✓', + copy: '複製', + copied: '已複製 ✓', }, en: { title: 'OpenLess Remote Input', @@ -117,6 +123,7 @@ btnConnect: 'Connect', btnConnecting: 'Connecting…', modeToggle: 'Tap', + insertLabel: 'Type on PC', modeHold: 'Hold', offlineTitle: 'Disconnected', offlineSub: 'The connection to your computer was lost.', @@ -156,6 +163,8 @@ helpDownloadCert: '⬇ Download & install cert', helpCopyLink: '⧉ Copy link', helpCopied: 'Copied ✓', + copy: 'Copy', + copied: 'Copied ✓', }, ja: { title: 'OpenLess リモート入力', @@ -165,6 +174,7 @@ btnConnect: '接続', btnConnecting: '接続中…', modeToggle: 'タップ', + insertLabel: 'PCに入力', modeHold: '長押し', offlineTitle: '接続が切断されました', offlineSub: 'パソコンとの接続が切断されました。', @@ -204,6 +214,8 @@ helpDownloadCert: '⬇ 証明書をインストール', helpCopyLink: '⧉ リンクをコピー', helpCopied: 'コピーしました ✓', + copy: 'コピー', + copied: 'コピー済み ✓', }, ko: { title: 'OpenLess 원격 입력', @@ -213,6 +225,7 @@ btnConnect: '연결', btnConnecting: '연결 중…', modeToggle: '탭', + insertLabel: 'PC에 입력', modeHold: '길게 누르기', offlineTitle: '연결이 끊겼습니다', offlineSub: '컴퓨터와의 연결이 끊겼습니다.', @@ -252,6 +265,8 @@ helpDownloadCert: '⬇ 인증서 설치', helpCopyLink: '⧉ 링크 복사', helpCopied: '복사됨 ✓', + copy: '복사', + copied: '복사됨 ✓', }, }; @@ -293,6 +308,7 @@ var TARGET_SR = 16000; // 目标采样率,必须与 PC 端一致 var MODE_KEY = 'ol_remote_mode'; // localStorage 键:录音方式 var PIN_KEY = 'ol_remote_pin'; // localStorage 键:上次成功的配对码 + var INSERT_KEY = 'ol_remote_insert'; // localStorage 键:电脑落字开关(默认开) var MIC_PREP_TIMEOUT_MS = 10000; // 麦克风准备超时:超过则判失败让用户重试,避免无限卡"准备中" // ---------- DOM ---------- @@ -309,9 +325,15 @@ var recordLabel = $('record-label'); var statusBar = $('status-bar'); var statusText = $('status-text'); + var statusIcon = $('status-icon'); + var statusDots = $('status-dots'); + var resultWrap = $('result-wrap'); + var resultText = $('result-text'); + var resultCopy = $('result-copy'); var levelBar = $('level-bar'); var recTip = $('rec-tip'); var modeSwitch = $('mode-switch'); + var insertSwitch = $('insert-switch'); var btnReconnect = $('btn-reconnect'); var offlineReason = $('offline-reason'); @@ -364,6 +386,28 @@ // ============================================================ // 模式(toggle / hold) // ============================================================ + // ============================================================ + // 电脑落字开关(关闭=只把文字回传手机、不落到电脑光标) + // ============================================================ + function readInsert() { + try { return localStorage.getItem(INSERT_KEY) !== '0'; } catch (e) { return true; } + } + function writeInsert(v) { + try { localStorage.setItem(INSERT_KEY, v ? '1' : '0'); } catch (e) {} + } + // 把当前开关值发给电脑(仅已连接时生效):进录音屏时同步一次,之后每次切换即时下发。 + function sendInsertConfig() { + wsSendJSON({ type: 'set_insert', value: insertSwitch ? insertSwitch.checked : true }); + } + function initInsertSwitch() { + if (!insertSwitch) return; + insertSwitch.checked = readInsert(); + insertSwitch.addEventListener('change', function () { + writeInsert(insertSwitch.checked); + sendInsertConfig(); + }); + } + function readMode() { var m = null; try { m = localStorage.getItem(MODE_KEY); } catch (e) {} @@ -406,6 +450,9 @@ // ============================================================ function setStatus(text, kind) { statusText.textContent = text; + // 每次切状态先清掉图标/三点动效,由调用方(applyStatusKind)按需重新点亮。 + if (statusIcon) statusIcon.hidden = true; + if (statusDots) statusDots.hidden = true; statusBar.classList.remove('is-error', 'is-ok', 'is-work'); if (kind === 'error') statusBar.classList.add('is-error'); else if (kind === 'ok') statusBar.classList.add('is-ok'); @@ -417,6 +464,28 @@ levelBar.style.width = (v * 100).toFixed(1) + '%'; } + // 去掉状态文案开头的 emoji 图标(如 '🎤 录音中' → '录音中'),改用 DOM 图标/动效呈现。 + function stripLeadingIcon(s) { + return String(s).replace(/^\S+\s+/, ''); + } + + // PC 端落字完成后回传的最终文字,显示在状态区下方;开始新一次录音时清空。 + function showResult(text) { + if (!resultWrap) return; + if (!text) { clearResult(); return; } + resultText.textContent = text; + resultWrap.hidden = false; + } + function clearResult() { + if (!resultWrap) return; + resultWrap.hidden = true; + resultText.textContent = ''; + if (resultCopy) { + resultCopy.classList.remove('copied'); + resultCopy.textContent = L.copy || '复制'; + } + } + // done 后过几秒自动回到"准备就绪",方便直接开始下一次,而不是一直停在结果上。 var readyTimer = null; function scheduleReady() { @@ -562,23 +631,30 @@ if (!recording) setStatus(L.ready, null); }, 1500); break; + + case 'result': + // 电脑落字完成后回传的最终文字,显示给手机用户看本次识别结果。 + showResult(msg.text); + break; } } function applyStatusKind(msg) { switch (msg.kind) { case 'recording': - setStatus(L.statusRecording, 'work'); + setStatus(stripLeadingIcon(L.statusRecording), 'work'); break; case 'transcribing': - setStatus(L.statusTranscribing, 'work'); + setStatus(stripLeadingIcon(L.statusTranscribing), 'work'); + if (statusDots) statusDots.hidden = false; // 识别中:三点加载动效 break; case 'polishing': - setStatus(L.statusPolishing, 'work'); + setStatus(L.statusPolishing, 'work'); // 润色保留 ✨ break; case 'done': var n = (typeof msg.insertedChars === 'number') ? msg.insertedChars : 0; - setStatus(fmt(L.statusDone, { n: n }), 'ok'); + setStatus(stripLeadingIcon(fmt(L.statusDone, { n: n })), 'ok'); + if (statusIcon) { statusIcon.src = '/done.png'; statusIcon.hidden = false; } // 完成:对勾图 setLevel(0); scheduleReady(); break; @@ -603,6 +679,7 @@ updateRecordBtnUI(); setStatus(L.ready, null); setLevel(0); + sendInsertConfig(); // 进录音屏时把「电脑落字」开关同步给电脑 } // ============================================================ @@ -686,6 +763,28 @@ }); } + // 结果文字「一键复制」:优先 navigator.clipboard(需安全上下文,本页是 HTTPS), + // 失败或旧浏览器回退 execCommand(兼容性高,见 fallbackCopyText)。 + if (resultCopy) { + resultCopy.addEventListener('click', function () { + var text = resultText.textContent || ''; + if (!text) return; + var done = function () { + resultCopy.classList.add('copied'); + resultCopy.textContent = L.copied || '已复制 ✓'; + setTimeout(function () { + resultCopy.classList.remove('copied'); + resultCopy.textContent = L.copy || '复制'; + }, 1500); + }; + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(text).then(done, function () { fallbackCopyText(text, done); }); + } else { + fallbackCopyText(text, done); + } + }); + } + // ============================================================ // 录音按钮交互(toggle / hold) // ============================================================ @@ -768,6 +867,7 @@ recording = true; updateRecordBtnUI(); setStatus(L.preparingMic, 'work'); + clearResult(); // 清掉上一次的识别结果,避免新录音时还显示旧文字 withTimeout(ensureAudio(), MIC_PREP_TIMEOUT_MS, 'TIMEOUT') .then(function () { @@ -777,7 +877,7 @@ return; } wsSendJSON({ type: 'start' }); - setStatus(L.statusRecording, 'work'); + setStatus(stripLeadingIcon(L.statusRecording), 'work'); }) .catch(function (err) { recording = false; @@ -797,7 +897,8 @@ updateRecordBtnUI(); teardownAudioCapture(); wsSendJSON({ type: 'stop' }); - setStatus(L.statusTranscribing, 'work'); + setStatus(stripLeadingIcon(L.statusTranscribing), 'work'); + if (statusDots) statusDots.hidden = false; setLevel(0); } @@ -1160,6 +1261,7 @@ applyStaticI18n(); syncModeUI(); + initInsertSwitch(); showScreen('pin'); showPinError(''); // 上次成功的配对码 → 自动填充并重连,刷新/重开页面免再输一次 diff --git a/openless-all/app/src-tauri/src/remote_server/assets/done.png b/openless-all/app/src-tauri/src/remote_server/assets/done.png new file mode 100644 index 0000000000000000000000000000000000000000..dc11332334cbdae8fd65198a1e126b9be6a7f1fe GIT binary patch literal 5605 zcmVf8F-ru&=Kx8vlFt*^sM zp`~9!K*@3mp^?Y!;Q!0V)i+LGKYdyKe*e7s{nPhdx;MN}5}DWUHqkQ~I!f-?E1&Dm zapl}|nVi1fuUA`=DXB(w*~Qb8EEgdkD%K7z z(S&7N2A4@URMD@rB^gKsGJ!;&zO1%HT65i`%LD|JECa#Yl0!5FNWo-qgb-3-AP+3m zdv=k5NmPB@5eXy$ft(gn@(hLj0$GL@H^2N_a){R0BL~q9mABj|dB%5|lBESZA;>W~ zL~FEUE_>H0d0MCI|m#%`0%`vpn;leuJ;#B z7KvDi2H7V_b?v}e_?n4B9GPQZ2N*GN7LErkk}NBj8XUEfh?SUEN|3We07izMMcYVp zZJF9;8d=UFoZrD6NjQQfVch&=%v_?z$8p+bS)^G6SynJ3es3aKN)kVO6m7$#K?r>z z0fE_)W#^ADNzk!=hKQDOiU9jIjx_f-r-$dWXLe+9B-!~R^XaV2Tf(Gi{F<2t17<}Q z+}fceNlvLf(#*@J2}+h&-L`eyagS82O0}L#2ry~bJVWGhCO%EkWLdGgM>ta^($sOk zG`qKs5r9V3WaAv zNF!V&jyY?Z6*{q56j^X(vf6~NR3#1-mDwTy-_pE(DNW0yB#Q;E$G6oc_s^VFO)dsu zCLgP1H~>&ed$K2s4F{7FrIZmhR89^6!2c{k%VbR!F}t9$hjxJGFAD+qpAs?4dtN3> zvNUFw;T~7Bi$Pe%hy74IN0YHxmK|C4%&ujO5pCrm0vVfSS&;>{D{alLLkx9mK?sOh zmS40i8?spcr24p^A)!q@j{rx@F!P^}ACsIch=K@Fh}1tJT@Debt|=qOKPg#26ynJ# z21tp*An2zJ0TAZ(`ziy+KN(q8-|kt>zJ6zTlqd{>el8#Y$3HP)l90s`MJ5_AAav%i z9$Fp(Ak5eMb*hF(CyNp#RaeiXUXGT^^g@E>L?w&$QBLWHaJ;y9wkOM zF1(INgb7L(ota$Xp6XD;C{%+V}(r zFkyba2D=&8J6WtlpZ#1)f{xTGqaeT+>Bg$@UY&QcG!DIYLA5|dpf>^_On{KGSF)%> z-@7|CV`u~dIQ-tova++O>d=QC1X_Mi1pNCiUdUqo6#Sc3&vs}wMIZ(OFvmRJrR;?) zc$zIn+vC-tHO&SA5XO`LGMOwk^1v!@Ht1?YW`}@%8h3KDCXq$VFJ|s!DYO7ZAS?nm z_M$$ml1XIIF!40-_3VfU@I_rN?h~pUO_qkK8vU98zMdKxfr+#GqsSr*Wu``MYChGe zBxNGNV^+rQNa;tBg(fm|q^x?{oDeYPiK;j6h|EjWW=)u^988uKUSY%xjoLu1Y(5Ay zB&mOSysO`@U#NO}8<#K~xgB}2A50eeXQ%V&J8elM1dd74RGWk_e6b&6jtwG*yNlg0@+NC#HRI7A#weCMbz|iOV!{fV4B*A$yu1uomp9X4|-egg&g09tc0fCJ* zxgORR^2uR&q=T>UvXSD8>mK5}^dgI>Nm5@WMSm7kXI0(RJxngw9w7=*`bq5p+NBp+ zGy+DtbR)pp27S$@JV8z*!SuJqle|fwcDwUmVG-dsJ z!cTMd4wY`QRPs~YQ$0E##D7I-G zb1o-jQ4`GKwU}=)yM{-*96qxn3L4fC+&U#okI5O7r>S%VnuoJ4JeYN^m9r#@ZE&k8 zc0d+uf>qZxzkbgcflb}>;jAtUW=xc($){1V1G1^JJd! zkSv@>KV>6uR(kPI#)XAlxhvel!jpGyd$#L}PUc$<$)X`+n&&dzKLIX66l1>N_xw|J zZQYVxOSfb>KCsK8zY0fSvVVg1uW*y$Vu*qkBOUrJTe7IX0&r%Qn)#Edt+GU+=c(ls zcm9#zvLj1JY`M4ws=yY3s=C9A?d-<*@5K@YkX6;ejg>pH5S=(Qq)V9yRCHUvZ+(Hp zp!SGqUd;0-gPJxfvyeqV8FjjzlotYi9C@@!#zd~q%tOFvY3H{HS%_Y*qt&eyg+joK zBM;#kqAVopg%+Pldwz>0%TH1hjEY{EC=a*ZT44XMRL8^-pq4C0J9V=l1PpzyKlmd` zRxiNIp&P2*?O&EG8VSpxTZYR3NB);-GxGbDuD;%{d0RCKgRuA6;$g``wB13vlz{+< zGU1Nl<3_IJHWn)Hx%uOU1lSfIj?2ArKlrzPia@|$#LG2zwaD3d*ZMwd8xeDwrOz29 z=Gl;;&lLMlKEol!j(orWJ?SfgTr71ir59XG7R#Dmxl78#B!S~w!=|sQyBQFMiQ>+ylRLTV4O=$`1p!`n(K45mQ z$!lnw>Mc*;aelWm*EO4uBClupRucKD3VsouXFx6%XReWj$%0k!iOrP$j&J>b{Zi*{ zmI?8v-wb)&v(`^B>Zcq7(_{OJ8hflq-dwXVQN}sGHOw*|l-ZeOEED`J-+)`9yngpx z?)=NEW}sQvVXr6J^D-#KETau2UraAkarvQux1%+ zCw8XOX&Dfux)btFZ_-H#@)r<9>d@1;<-h;-KmW#LG1ucxPk5G?DgN#GTAcqBb25fC z%G=vuM8OQbzKmq@fMv2wE+b+A7*rH&P@y3huytJLpM5R_C)O{h-*>Wp3O)NScZuaO zS*-I?PUGJM8C1vZuf_Qfu4R}gZ2lcr?sHrH6fbE0>Bpb`oyp>*tVo40vCWTXxmrf3 zQJ!NL=bts2|N4RxB{M(8KZ21Sq2mzZBn=nDSKFuq!zKBxV_nos$Vp z6vCo@3Q+K#m@HKkhuOR2u0Rfn#w85QGIq)h4Sh}~3Q-y?bUww64^Qj=`_KRVCzHif zX(@|o2ouZkM^o!zb24#=@{P?e#scz{m-3hRicDz^eg~)+By4^}B@7+~fLUhDQ-@U8 z9gip+DMW>ud?R4Q`kIGk*kgRL(LtvM};I zU?LNSE2S2*EE-W(2on>jpF&|5CQJ4AuvMfe9fUz>`9A6Z+D6OREUPEp4c!+t(V|Af z#C`D^m#=Efuq*pxTp^jC#e-;trvf92DR(f#EQ?jAm|w9fOIS|^RkLQX&PxWegb^X~ zTuhiow3oV4^Q#>g-+K-HKZ<0s#JMpMl_g6YDZ~7Om@LVJ@LRF8g*B(&Qnj>m2wcHz zNcX_gIng{HW5VFC0A-MQE;>nZYJSCo{fgv{4c08QoCSp8K~_3p)clG&WF>IJRa&wb z66Vk{mgsXaK4BH2tNC>TX}BFbpWc!sTuqslTwTYWyz~ad&sTllmp3qn6=cbBbtT^Z zuv1IVT^?aXtH6{j9r}{wH`QOAxRxyQdCR?fY>fBlleB$GNp*nMgAcOV4XiaqpKkmHanSGx%q z9MmKTq#=yX)C>5@zK;AA$Z=S+Iksl1C^> zmm>tKc=wZOsQ3u`*qi3XDvrZ?Psw8JQ(=eMbjuNB}UAL0ny=yUZ%KL{KvjHc8Rve+jlt5#+;=jaLu82^Nk2}q)- zZ=@kzju2oI3~w7f+KbH-vS24w8Wj^9E`14M{^HMM(h0~*h=)Jk5(RUWwvt6>^Bp`r z);z#h9`0Dy3CJE;$H*dXNP9)CWRa|fmh`AGOV3QOr+XHU>NFq000G!NklTZnWgUKS5j8I{6o_;9; zsSz;d$=)P9T#!*@QSCAuXgwnb1k6kQ%i}1m0_jJQ1t(JV_LfGz*h0?NFGV0W0uwEw z(PU9)KXy-ReG?Jb)IDyNJXgnPvY=2~xjOs6LR01lRBuL`U#?}7$RdS|j4a5x`lSfO zK)}#vv4kepHJL0DSzuu%%Auz#0)Y^KQ~xmhQSTOMCX)qadN#*Uq$Wkc4+0X1@>7%- zvWO@ml=?4%g?{CMKt;Dc%rDm%Z)6dnCWhvmP`XwGCL_SEfw%~h3)&UnjVvfq%rO=c zzcncW2@#kaCT^46D_Ky15Jo2}0fnX{2(Ux{;ejX2{a5atEFxl1|3zxhFGZjofq+vP zLC7NhOLe7hg=z0j-5Ux4b^(0*Df^-ZAqy&yH}R{(4=6OnK_G~qvM+itvY-<2UzqLq ztw|9Gh`^=}XOglZ3-GXn zN&50Wh@mMd0&HD?DDNLONyTWXJS(z5KHT14YiJozEHSYopElxk4)pBE;)JbXtMU~_ zUlHpNnfV(2NhXeb7t-011p@EPGRjURlP;bRXw0t6{FAoEWla_cY|S!`vGbKjwG0rM zRm$4zIz~EsvN(ZjTzQg5%bYa2o{RvS*mdS+*Kr$*AqxbDSypeIe^%evsi`ysHdMVy zdfUoaz=|S^Yh*3Xzx>tN#_iK}?g;S3tG0L*(Utl+H9G zT^a;7#2UY5bGcNLt7*r$l~Ba zM{P+1Q$QbMcF=@c&zd9-v{{nHHBLywrU1b_Q_?tC{H=j0YjIqdfVIDZ^DJrBWO0y- zdB&uvb&keCGHop+;kgoEn&I8S(;C-(_9Do#-+Xa8K^ndy^8QWC2pZQ{@@GT1g8~zZ zLX!1M$&DE!`{KyrK!Vv&vm7yKr*<37HTo0r3YYsct zPTS~w4PQ4fKSHS9!K%T=2y>6Ko>L3Tq zP_I?ZxG*Kl$j&__&zpBtk<~up_fQHAwAqoOy4z|E_Q$O6@!OMGM9>9h^i#aE2^CW|th>7KrVgCrJ*@i|qNX_{Y#^v(ZjM|Zc>lB9^ zq(E9;c$We$N|u1&x}t?dJb5RfR}g8N9`EFekFe}EJ-(}<3EVaY|3P|K*Es(@$bhtd z0a0CDx(OpenLess 远程输入 - -
+ +

点击大按钮开始录音,再次点击结束并识别。

+ + + diff --git a/openless-all/app/src-tauri/src/remote_server/assets/mic.png b/openless-all/app/src-tauri/src/remote_server/assets/mic.png new file mode 100644 index 0000000000000000000000000000000000000000..1e0570ee8962c5088fc457260c5dabfe968a1385 GIT binary patch literal 4327 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 Lj%u;8Md1GdX3F^% literal 0 HcmV?d00001 diff --git a/openless-all/app/src-tauri/src/remote_server/assets/style.css b/openless-all/app/src-tauri/src/remote_server/assets/style.css index 92265dbb..026c3625 100644 --- a/openless-all/app/src-tauri/src/remote_server/assets/style.css +++ b/openless-all/app/src-tauri/src/remote_server/assets/style.css @@ -51,6 +51,10 @@ -webkit-tap-highlight-color: transparent; } +/* 关键:很多元素用 hidden 属性控制显隐,但元素自带 display(flex/inline-flex)会覆盖浏览器 + 默认的 [hidden]{display:none},导致空框照常显示。这条强制 hidden 优先(结果框/三点/图标都靠它)。 */ +[hidden] { display: none !important; } + html, body { margin: 0; padding: 0; @@ -336,7 +340,9 @@ body { pointer-events: none; } .record-btn-icon { - font-size: 50px; + width: 60px; + height: 60px; + object-fit: contain; line-height: 1; transition: transform .15s ease; } @@ -419,6 +425,82 @@ body { .status-bar.is-ok { color: var(--ok); border-color: rgba(22, 163, 74, .35); } .status-bar.is-work { color: var(--blue); border-color: var(--blue-ring); } +/* 状态图标(如完成对勾) */ +.status-icon { + width: 18px; + height: 18px; + object-fit: contain; + vertical-align: -3px; + margin-right: 3px; +} + +/* 识别中三点加载动效(替代旋转 emoji) */ +.dots { + display: inline-flex; + align-items: center; + gap: 3px; + margin-right: 5px; + vertical-align: middle; + color: var(--blue); +} +.dots i { + width: 5px; + height: 5px; + border-radius: 50%; + background: currentColor; + display: inline-block; + animation: dotPulse 1.2s infinite ease-in-out both; +} +.dots i:nth-child(1) { animation-delay: -.32s; } +.dots i:nth-child(2) { animation-delay: -.16s; } +@keyframes dotPulse { + 0%, 80%, 100% { transform: scale(.5); opacity: .35; } + 40% { transform: scale(1); opacity: 1; } +} + +/* ===== 识别结果文字(电脑回传) ===== */ +.result-wrap { + max-width: 90%; + margin-top: 2px; + display: flex; + flex-direction: column; + gap: 8px; + animation: fadeIn .25s ease; +} +.result-text { + padding: 12px 16px; + border-radius: var(--r-lg); + background: var(--surface); + border: 0.5px solid var(--line); + box-shadow: var(--shadow-sm); + font-size: 15px; + line-height: 1.55; + color: var(--ink); + text-align: left; + white-space: pre-wrap; + word-break: break-word; + /* 结果文字允许选中复制(其余 UI 默认禁选) */ + user-select: text; + -webkit-user-select: text; +} +.result-copy { + align-self: flex-end; + -webkit-appearance: none; + appearance: none; + border: 1px solid var(--blue); + background: var(--blue-soft); + color: var(--blue); + font-size: 13px; + font-weight: 600; + font-family: inherit; + padding: 8px 18px; + border-radius: var(--r-lg); + cursor: pointer; + transition: background .15s ease, color .15s ease; +} +.result-copy:active { background: var(--blue); color: #fff; } +.result-copy.copied { background: var(--ok-soft); border-color: var(--ok); color: var(--ok); } + /* ===== 提示文字 ===== */ .rec-tip { text-align: center; @@ -427,6 +509,52 @@ body { margin: 18px 0 0; } +/* ===== 电脑落字开关 ===== */ +.insert-toggle { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + margin: 14px 0 0; + font-size: 13px; + color: var(--ink-3); + cursor: pointer; +} +.insert-switch { + position: absolute; + opacity: 0; + width: 0; + height: 0; + pointer-events: none; +} +.insert-track { + position: relative; + width: 42px; + height: 24px; + border-radius: 999px; + background: var(--line-strong); + transition: background .2s ease; + flex: none; +} +.insert-track::after { + content: ''; + position: absolute; + top: 2px; + left: 2px; + width: 20px; + height: 20px; + border-radius: 50%; + background: #fff; + box-shadow: 0 1px 3px rgba(0, 0, 0, .25); + transition: transform .2s ease; +} +.insert-switch:checked ~ .insert-track { + background: var(--blue); +} +.insert-switch:checked ~ .insert-track::after { + transform: translateX(18px); +} + /* ===== 断线屏 ===== */ .offline-icon { font-size: 48px; } .offline-title { font-size: 20px; margin: 12px 0 6px; color: var(--ink); } @@ -454,5 +582,5 @@ body { .brand { margin: 14px 0; } .brand-logo-img { width: 56px; height: 56px; } .record-btn { width: 148px; height: 148px; } - .record-btn-icon { font-size: 44px; } + .record-btn-icon { width: 52px; height: 52px; } } diff --git a/openless-all/app/src-tauri/src/remote_server/mod.rs b/openless-all/app/src-tauri/src/remote_server/mod.rs index 46b49e31..d64661b1 100644 --- a/openless-all/app/src-tauri/src/remote_server/mod.rs +++ b/openless-all/app/src-tauri/src/remote_server/mod.rs @@ -35,6 +35,8 @@ mod assets { pub const APP_JS: &str = include_str!("assets/app.js"); pub const STYLE_CSS: &str = include_str!("assets/style.css"); pub const ICON_PNG: &[u8] = include_bytes!("assets/icon.png"); + pub const MIC_PNG: &[u8] = include_bytes!("assets/mic.png"); + pub const DONE_PNG: &[u8] = include_bytes!("assets/done.png"); } const HEADER_HTML: &str = "text/html; charset=utf-8"; @@ -275,6 +277,18 @@ fn build_router(state: Arc) -> Router { ([(axum::http::header::CONTENT_TYPE, "image/png")], assets::ICON_PNG) }), ) + .route( + "/mic.png", + get(|| async { + ([(axum::http::header::CONTENT_TYPE, "image/png")], assets::MIC_PNG) + }), + ) + .route( + "/done.png", + get(|| async { + ([(axum::http::header::CONTENT_TYPE, "image/png")], assets::DONE_PNG) + }), + ) // 证书下载:手机在浏览器打开它即可下载并安装信任(iOS Safari 的 wss 不复用 // 页面级证书例外,需在系统里完全信任后 wss 才稳定)。 .route( @@ -520,6 +534,18 @@ async fn handle_ws(mut socket: WebSocket, state: Arc) { }) }; + // 听写完成后 PC 端把最终文字 emit 到 "remote:result"。手机用户看不到电脑屏幕, + // 所以把这次落下的完整文字转发过去,H5 在状态区下方显示(type=result)。 + let result_listener_id = { + let tx = evt_tx.clone(); + state.app.listen_any("remote:result", move |event| { + // emit 的是 String,payload 是带引号的 JSON 字符串,反序列化回纯文本。 + if let Ok(text) = serde_json::from_str::(event.payload()) { + let _ = tx.send(serde_json::json!({ "type": "result", "text": text }).to_string()); + } + }) + }; + // 3) 主循环:手机上行(控制 / PCM) + 后端状态下行。 loop { tokio::select! { @@ -550,6 +576,7 @@ async fn handle_ws(mut socket: WebSocket, state: Arc) { // 4) 收尾:断连即取消未完成的远程会话,避免 ASR 句柄悬挂。 log::info!("[remote-input] WS 连接已关闭"); state.app.unlisten(listener_id); + state.app.unlisten(result_listener_id); state.coordinator.cancel_remote_dictation(); } @@ -579,6 +606,12 @@ async fn handle_control(txt: &str, state: &Arc, socket: &mut WebSocket) "cancel" => { state.coordinator.cancel_remote_dictation(); } + "set_insert" => { + // 手机端「电脑落字」开关:value=true 表示要落字。no_insert = !value。 + let insert = v.get("value").and_then(|b| b.as_bool()).unwrap_or(true); + state.coordinator.set_remote_no_insert(!insert); + log::info!("[remote-input] 电脑落字开关 = {insert}"); + } _ => {} } true From 8abe3c6815115a8227748f7c3c8fe48705f3ad72 Mon Sep 17 00:00:00 2001 From: ciddwd <572242998@qq.com> Date: Sun, 7 Jun 2026 21:55:02 +0800 Subject: [PATCH 4/8] =?UTF-8?q?chore:=20=E4=BB=8E=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E5=BA=93=E7=A7=BB=E9=99=A4=20.spec-workflow=EF=BC=88spec-workf?= =?UTF-8?q?low=20MCP=20=E6=A8=A1=E6=9D=BF=EF=BC=8C=E8=AF=AF=E5=85=A5?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E5=88=86=E6=94=AF=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .spec-workflow/templates/design-template.md | 96 ------------ .spec-workflow/templates/product-template.md | 51 ------ .../templates/requirements-template.md | 50 ------ .../templates/structure-template.md | 145 ------------------ .spec-workflow/templates/tasks-template.md | 139 ----------------- .spec-workflow/templates/tech-template.md | 99 ------------ .spec-workflow/user-templates/README.md | 64 -------- 7 files changed, 644 deletions(-) delete mode 100644 .spec-workflow/templates/design-template.md delete mode 100644 .spec-workflow/templates/product-template.md delete mode 100644 .spec-workflow/templates/requirements-template.md delete mode 100644 .spec-workflow/templates/structure-template.md delete mode 100644 .spec-workflow/templates/tasks-template.md delete mode 100644 .spec-workflow/templates/tech-template.md delete mode 100644 .spec-workflow/user-templates/README.md diff --git a/.spec-workflow/templates/design-template.md b/.spec-workflow/templates/design-template.md deleted file mode 100644 index 1295d7b4..00000000 --- a/.spec-workflow/templates/design-template.md +++ /dev/null @@ -1,96 +0,0 @@ -# Design Document - -## Overview - -[High-level description of the feature and its place in the overall system] - -## Steering Document Alignment - -### Technical Standards (tech.md) -[How the design follows documented technical patterns and standards] - -### Project Structure (structure.md) -[How the implementation will follow project organization conventions] - -## Code Reuse Analysis -[What existing code will be leveraged, extended, or integrated with this feature] - -### Existing Components to Leverage -- **[Component/Utility Name]**: [How it will be used] -- **[Service/Helper Name]**: [How it will be extended] - -### Integration Points -- **[Existing System/API]**: [How the new feature will integrate] -- **[Database/Storage]**: [How data will connect to existing schemas] - -## Architecture - -[Describe the overall architecture and design patterns used] - -### Modular Design Principles -- **Single File Responsibility**: Each file should handle one specific concern or domain -- **Component Isolation**: Create small, focused components rather than large monolithic files -- **Service Layer Separation**: Separate data access, business logic, and presentation layers -- **Utility Modularity**: Break utilities into focused, single-purpose modules - -```mermaid -graph TD - A[Component A] --> B[Component B] - B --> C[Component C] -``` - -## Components and Interfaces - -### Component 1 -- **Purpose:** [What this component does] -- **Interfaces:** [Public methods/APIs] -- **Dependencies:** [What it depends on] -- **Reuses:** [Existing components/utilities it builds upon] - -### Component 2 -- **Purpose:** [What this component does] -- **Interfaces:** [Public methods/APIs] -- **Dependencies:** [What it depends on] -- **Reuses:** [Existing components/utilities it builds upon] - -## Data Models - -### Model 1 -``` -[Define the structure of Model1 in your language] -- id: [unique identifier type] -- name: [string/text type] -- [Additional properties as needed] -``` - -### Model 2 -``` -[Define the structure of Model2 in your language] -- id: [unique identifier type] -- [Additional properties as needed] -``` - -## Error Handling - -### Error Scenarios -1. **Scenario 1:** [Description] - - **Handling:** [How to handle] - - **User Impact:** [What user sees] - -2. **Scenario 2:** [Description] - - **Handling:** [How to handle] - - **User Impact:** [What user sees] - -## Testing Strategy - -### Unit Testing -- [Unit testing approach] -- [Key components to test] - -### Integration Testing -- [Integration testing approach] -- [Key flows to test] - -### End-to-End Testing -- [E2E testing approach] -- [User scenarios to test] diff --git a/.spec-workflow/templates/product-template.md b/.spec-workflow/templates/product-template.md deleted file mode 100644 index 82e60de2..00000000 --- a/.spec-workflow/templates/product-template.md +++ /dev/null @@ -1,51 +0,0 @@ -# Product Overview - -## Product Purpose -[Describe the core purpose of this product/project. What problem does it solve?] - -## Target Users -[Who are the primary users of this product? What are their needs and pain points?] - -## Key Features -[List the main features that deliver value to users] - -1. **Feature 1**: [Description] -2. **Feature 2**: [Description] -3. **Feature 3**: [Description] - -## Business Objectives -[What are the business goals this product aims to achieve?] - -- [Objective 1] -- [Objective 2] -- [Objective 3] - -## Success Metrics -[How will we measure the success of this product?] - -- [Metric 1]: [Target] -- [Metric 2]: [Target] -- [Metric 3]: [Target] - -## Product Principles -[Core principles that guide product decisions] - -1. **[Principle 1]**: [Explanation] -2. **[Principle 2]**: [Explanation] -3. **[Principle 3]**: [Explanation] - -## Monitoring & Visibility (if applicable) -[How do users track progress and monitor the system?] - -- **Dashboard Type**: [e.g., Web-based, CLI, Desktop app] -- **Real-time Updates**: [e.g., WebSocket, polling, push notifications] -- **Key Metrics Displayed**: [What information is most important to surface] -- **Sharing Capabilities**: [e.g., read-only links, exports, reports] - -## Future Vision -[Where do we see this product evolving in the future?] - -### Potential Enhancements -- **Remote Access**: [e.g., Tunnel features for sharing dashboards with stakeholders] -- **Analytics**: [e.g., Historical trends, performance metrics] -- **Collaboration**: [e.g., Multi-user support, commenting] diff --git a/.spec-workflow/templates/requirements-template.md b/.spec-workflow/templates/requirements-template.md deleted file mode 100644 index 1c80ca0d..00000000 --- a/.spec-workflow/templates/requirements-template.md +++ /dev/null @@ -1,50 +0,0 @@ -# Requirements Document - -## Introduction - -[Provide a brief overview of the feature, its purpose, and its value to users] - -## Alignment with Product Vision - -[Explain how this feature supports the goals outlined in product.md] - -## Requirements - -### Requirement 1 - -**User Story:** As a [role], I want [feature], so that [benefit] - -#### Acceptance Criteria - -1. WHEN [event] THEN [system] SHALL [response] -2. IF [precondition] THEN [system] SHALL [response] -3. WHEN [event] AND [condition] THEN [system] SHALL [response] - -### Requirement 2 - -**User Story:** As a [role], I want [feature], so that [benefit] - -#### Acceptance Criteria - -1. WHEN [event] THEN [system] SHALL [response] -2. IF [precondition] THEN [system] SHALL [response] - -## Non-Functional Requirements - -### Code Architecture and Modularity -- **Single Responsibility Principle**: Each file should have a single, well-defined purpose -- **Modular Design**: Components, utilities, and services should be isolated and reusable -- **Dependency Management**: Minimize interdependencies between modules -- **Clear Interfaces**: Define clean contracts between components and layers - -### Performance -- [Performance requirements] - -### Security -- [Security requirements] - -### Reliability -- [Reliability requirements] - -### Usability -- [Usability requirements] diff --git a/.spec-workflow/templates/structure-template.md b/.spec-workflow/templates/structure-template.md deleted file mode 100644 index 1ab1fbcc..00000000 --- a/.spec-workflow/templates/structure-template.md +++ /dev/null @@ -1,145 +0,0 @@ -# Project Structure - -## Directory Organization - -``` -[Define your project's directory structure. Examples below - adapt to your project type] - -Example for a library/package: -project-root/ -├── src/ # Source code -├── tests/ # Test files -├── docs/ # Documentation -├── examples/ # Usage examples -└── [build/dist/out] # Build output - -Example for an application: -project-root/ -├── [src/app/lib] # Main source code -├── [assets/resources] # Static resources -├── [config/settings] # Configuration -├── [scripts/tools] # Build/utility scripts -└── [tests/spec] # Test files - -Common patterns: -- Group by feature/module -- Group by layer (UI, business logic, data) -- Group by type (models, controllers, views) -- Flat structure for simple projects -``` - -## Naming Conventions - -### Files -- **Components/Modules**: [e.g., `PascalCase`, `snake_case`, `kebab-case`] -- **Services/Handlers**: [e.g., `UserService`, `user_service`, `user-service`] -- **Utilities/Helpers**: [e.g., `dateUtils`, `date_utils`, `date-utils`] -- **Tests**: [e.g., `[filename]_test`, `[filename].test`, `[filename]Test`] - -### Code -- **Classes/Types**: [e.g., `PascalCase`, `CamelCase`, `snake_case`] -- **Functions/Methods**: [e.g., `camelCase`, `snake_case`, `PascalCase`] -- **Constants**: [e.g., `UPPER_SNAKE_CASE`, `SCREAMING_CASE`, `PascalCase`] -- **Variables**: [e.g., `camelCase`, `snake_case`, `lowercase`] - -## Import Patterns - -### Import Order -1. External dependencies -2. Internal modules -3. Relative imports -4. Style imports - -### Module/Package Organization -``` -[Describe your project's import/include patterns] -Examples: -- Absolute imports from project root -- Relative imports within modules -- Package/namespace organization -- Dependency management approach -``` - -## Code Structure Patterns - -[Define common patterns for organizing code within files. Below are examples - choose what applies to your project] - -### Module/Class Organization -``` -Example patterns: -1. Imports/includes/dependencies -2. Constants and configuration -3. Type/interface definitions -4. Main implementation -5. Helper/utility functions -6. Exports/public API -``` - -### Function/Method Organization -``` -Example patterns: -- Input validation first -- Core logic in the middle -- Error handling throughout -- Clear return points -``` - -### File Organization Principles -``` -Choose what works for your project: -- One class/module per file -- Related functionality grouped together -- Public API at the top/bottom -- Implementation details hidden -``` - -## Code Organization Principles - -1. **Single Responsibility**: Each file should have one clear purpose -2. **Modularity**: Code should be organized into reusable modules -3. **Testability**: Structure code to be easily testable -4. **Consistency**: Follow patterns established in the codebase - -## Module Boundaries -[Define how different parts of your project interact and maintain separation of concerns] - -Examples of boundary patterns: -- **Core vs Plugins**: Core functionality vs extensible plugins -- **Public API vs Internal**: What's exposed vs implementation details -- **Platform-specific vs Cross-platform**: OS-specific code isolation -- **Stable vs Experimental**: Production code vs experimental features -- **Dependencies direction**: Which modules can depend on which - -## Code Size Guidelines -[Define your project's guidelines for file and function sizes] - -Suggested guidelines: -- **File size**: [Define maximum lines per file] -- **Function/Method size**: [Define maximum lines per function] -- **Class/Module complexity**: [Define complexity limits] -- **Nesting depth**: [Maximum nesting levels] - -## Dashboard/Monitoring Structure (if applicable) -[How dashboard or monitoring components are organized] - -### Example Structure: -``` -src/ -└── dashboard/ # Self-contained dashboard subsystem - ├── server/ # Backend server components - ├── client/ # Frontend assets - ├── shared/ # Shared types/utilities - └── public/ # Static assets -``` - -### Separation of Concerns -- Dashboard isolated from core business logic -- Own CLI entry point for independent operation -- Minimal dependencies on main application -- Can be disabled without affecting core functionality - -## Documentation Standards -- All public APIs must have documentation -- Complex logic should include inline comments -- README files for major modules -- Follow language-specific documentation conventions diff --git a/.spec-workflow/templates/tasks-template.md b/.spec-workflow/templates/tasks-template.md deleted file mode 100644 index be461de5..00000000 --- a/.spec-workflow/templates/tasks-template.md +++ /dev/null @@ -1,139 +0,0 @@ -# Tasks Document - -- [ ] 1. Create core interfaces in src/types/feature.ts - - File: src/types/feature.ts - - Define TypeScript interfaces for feature data structures - - Extend existing base interfaces from base.ts - - Purpose: Establish type safety for feature implementation - - _Leverage: src/types/base.ts_ - - _Requirements: 1.1_ - - _Prompt: Role: TypeScript Developer specializing in type systems and interfaces | Task: Create comprehensive TypeScript interfaces for the feature data structures following requirements 1.1, extending existing base interfaces from src/types/base.ts | Restrictions: Do not modify existing base interfaces, maintain backward compatibility, follow project naming conventions | Success: All interfaces compile without errors, proper inheritance from base types, full type coverage for feature requirements_ - -- [ ] 2. Create base model class in src/models/FeatureModel.ts - - File: src/models/FeatureModel.ts - - Implement base model extending BaseModel class - - Add validation methods using existing validation utilities - - Purpose: Provide data layer foundation for feature - - _Leverage: src/models/BaseModel.ts, src/utils/validation.ts_ - - _Requirements: 2.1_ - - _Prompt: Role: Backend Developer with expertise in Node.js and data modeling | Task: Create a base model class extending BaseModel and implementing validation following requirement 2.1, leveraging existing patterns from src/models/BaseModel.ts and src/utils/validation.ts | Restrictions: Must follow existing model patterns, do not bypass validation utilities, maintain consistent error handling | Success: Model extends BaseModel correctly, validation methods implemented and tested, follows project architecture patterns_ - -- [ ] 3. Add specific model methods to FeatureModel.ts - - File: src/models/FeatureModel.ts (continue from task 2) - - Implement create, update, delete methods - - Add relationship handling for foreign keys - - Purpose: Complete model functionality for CRUD operations - - _Leverage: src/models/BaseModel.ts_ - - _Requirements: 2.2, 2.3_ - - _Prompt: Role: Backend Developer with expertise in ORM and database operations | Task: Implement CRUD methods and relationship handling in FeatureModel.ts following requirements 2.2 and 2.3, extending patterns from src/models/BaseModel.ts | Restrictions: Must maintain transaction integrity, follow existing relationship patterns, do not duplicate base model functionality | Success: All CRUD operations work correctly, relationships are properly handled, database operations are atomic and efficient_ - -- [ ] 4. Create model unit tests in tests/models/FeatureModel.test.ts - - File: tests/models/FeatureModel.test.ts - - Write tests for model validation and CRUD methods - - Use existing test utilities and fixtures - - Purpose: Ensure model reliability and catch regressions - - _Leverage: tests/helpers/testUtils.ts, tests/fixtures/data.ts_ - - _Requirements: 2.1, 2.2_ - - _Prompt: Role: QA Engineer with expertise in unit testing and Jest/Mocha frameworks | Task: Create comprehensive unit tests for FeatureModel validation and CRUD methods covering requirements 2.1 and 2.2, using existing test utilities from tests/helpers/testUtils.ts and fixtures from tests/fixtures/data.ts | Restrictions: Must test both success and failure scenarios, do not test external dependencies directly, maintain test isolation | Success: All model methods are tested with good coverage, edge cases covered, tests run independently and consistently_ - -- [ ] 5. Create service interface in src/services/IFeatureService.ts - - File: src/services/IFeatureService.ts - - Define service contract with method signatures - - Extend base service interface patterns - - Purpose: Establish service layer contract for dependency injection - - _Leverage: src/services/IBaseService.ts_ - - _Requirements: 3.1_ - - _Prompt: Role: Software Architect specializing in service-oriented architecture and TypeScript interfaces | Task: Design service interface contract following requirement 3.1, extending base service patterns from src/services/IBaseService.ts for dependency injection | Restrictions: Must maintain interface segregation principle, do not expose internal implementation details, ensure contract compatibility with DI container | Success: Interface is well-defined with clear method signatures, extends base service appropriately, supports all required service operations_ - -- [ ] 6. Implement feature service in src/services/FeatureService.ts - - File: src/services/FeatureService.ts - - Create concrete service implementation using FeatureModel - - Add error handling with existing error utilities - - Purpose: Provide business logic layer for feature operations - - _Leverage: src/services/BaseService.ts, src/utils/errorHandler.ts, src/models/FeatureModel.ts_ - - _Requirements: 3.2_ - - _Prompt: Role: Backend Developer with expertise in service layer architecture and business logic | Task: Implement concrete FeatureService following requirement 3.2, using FeatureModel and extending BaseService patterns with proper error handling from src/utils/errorHandler.ts | Restrictions: Must implement interface contract exactly, do not bypass model validation, maintain separation of concerns from data layer | Success: Service implements all interface methods correctly, robust error handling implemented, business logic is well-encapsulated and testable_ - -- [ ] 7. Add service dependency injection in src/utils/di.ts - - File: src/utils/di.ts (modify existing) - - Register FeatureService in dependency injection container - - Configure service lifetime and dependencies - - Purpose: Enable service injection throughout application - - _Leverage: existing DI configuration in src/utils/di.ts_ - - _Requirements: 3.1_ - - _Prompt: Role: DevOps Engineer with expertise in dependency injection and IoC containers | Task: Register FeatureService in DI container following requirement 3.1, configuring appropriate lifetime and dependencies using existing patterns from src/utils/di.ts | Restrictions: Must follow existing DI container patterns, do not create circular dependencies, maintain service resolution efficiency | Success: FeatureService is properly registered and resolvable, dependencies are correctly configured, service lifetime is appropriate for use case_ - -- [ ] 8. Create service unit tests in tests/services/FeatureService.test.ts - - File: tests/services/FeatureService.test.ts - - Write tests for service methods with mocked dependencies - - Test error handling scenarios - - Purpose: Ensure service reliability and proper error handling - - _Leverage: tests/helpers/testUtils.ts, tests/mocks/modelMocks.ts_ - - _Requirements: 3.2, 3.3_ - - _Prompt: Role: QA Engineer with expertise in service testing and mocking frameworks | Task: Create comprehensive unit tests for FeatureService methods covering requirements 3.2 and 3.3, using mocked dependencies from tests/mocks/modelMocks.ts and test utilities | Restrictions: Must mock all external dependencies, test business logic in isolation, do not test framework code | Success: All service methods tested with proper mocking, error scenarios covered, tests verify business logic correctness and error handling_ - -- [ ] 4. Create API endpoints - - Design API structure - - _Leverage: src/api/baseApi.ts, src/utils/apiUtils.ts_ - - _Requirements: 4.0_ - - _Prompt: Role: API Architect specializing in RESTful design and Express.js | Task: Design comprehensive API structure following requirement 4.0, leveraging existing patterns from src/api/baseApi.ts and utilities from src/utils/apiUtils.ts | Restrictions: Must follow REST conventions, maintain API versioning compatibility, do not expose internal data structures directly | Success: API structure is well-designed and documented, follows existing patterns, supports all required operations with proper HTTP methods and status codes_ - -- [ ] 4.1 Set up routing and middleware - - Configure application routes - - Add authentication middleware - - Set up error handling middleware - - _Leverage: src/middleware/auth.ts, src/middleware/errorHandler.ts_ - - _Requirements: 4.1_ - - _Prompt: Role: Backend Developer with expertise in Express.js middleware and routing | Task: Configure application routes and middleware following requirement 4.1, integrating authentication from src/middleware/auth.ts and error handling from src/middleware/errorHandler.ts | Restrictions: Must maintain middleware order, do not bypass security middleware, ensure proper error propagation | Success: Routes are properly configured with correct middleware chain, authentication works correctly, errors are handled gracefully throughout the request lifecycle_ - -- [ ] 4.2 Implement CRUD endpoints - - Create API endpoints - - Add request validation - - Write API integration tests - - _Leverage: src/controllers/BaseController.ts, src/utils/validation.ts_ - - _Requirements: 4.2, 4.3_ - - _Prompt: Role: Full-stack Developer with expertise in API development and validation | Task: Implement CRUD endpoints following requirements 4.2 and 4.3, extending BaseController patterns and using validation utilities from src/utils/validation.ts | Restrictions: Must validate all inputs, follow existing controller patterns, ensure proper HTTP status codes and responses | Success: All CRUD operations work correctly, request validation prevents invalid data, integration tests pass and cover all endpoints_ - -- [ ] 5. Add frontend components - - Plan component architecture - - _Leverage: src/components/BaseComponent.tsx, src/styles/theme.ts_ - - _Requirements: 5.0_ - - _Prompt: Role: Frontend Architect with expertise in React component design and architecture | Task: Plan comprehensive component architecture following requirement 5.0, leveraging base patterns from src/components/BaseComponent.tsx and theme system from src/styles/theme.ts | Restrictions: Must follow existing component patterns, maintain design system consistency, ensure component reusability | Success: Architecture is well-planned and documented, components are properly organized, follows existing patterns and theme system_ - -- [ ] 5.1 Create base UI components - - Set up component structure - - Implement reusable components - - Add styling and theming - - _Leverage: src/components/BaseComponent.tsx, src/styles/theme.ts_ - - _Requirements: 5.1_ - - _Prompt: Role: Frontend Developer specializing in React and component architecture | Task: Create reusable UI components following requirement 5.1, extending BaseComponent patterns and using existing theme system from src/styles/theme.ts | Restrictions: Must use existing theme variables, follow component composition patterns, ensure accessibility compliance | Success: Components are reusable and properly themed, follow existing architecture, accessible and responsive_ - -- [ ] 5.2 Implement feature-specific components - - Create feature components - - Add state management - - Connect to API endpoints - - _Leverage: src/hooks/useApi.ts, src/components/BaseComponent.tsx_ - - _Requirements: 5.2, 5.3_ - - _Prompt: Role: React Developer with expertise in state management and API integration | Task: Implement feature-specific components following requirements 5.2 and 5.3, using API hooks from src/hooks/useApi.ts and extending BaseComponent patterns | Restrictions: Must use existing state management patterns, handle loading and error states properly, maintain component performance | Success: Components are fully functional with proper state management, API integration works smoothly, user experience is responsive and intuitive_ - -- [ ] 6. Integration and testing - - Plan integration approach - - _Leverage: src/utils/integrationUtils.ts, tests/helpers/testUtils.ts_ - - _Requirements: 6.0_ - - _Prompt: Role: Integration Engineer with expertise in system integration and testing strategies | Task: Plan comprehensive integration approach following requirement 6.0, leveraging integration utilities from src/utils/integrationUtils.ts and test helpers | Restrictions: Must consider all system components, ensure proper test coverage, maintain integration test reliability | Success: Integration plan is comprehensive and feasible, all system components work together correctly, integration points are well-tested_ - -- [ ] 6.1 Write end-to-end tests - - Set up E2E testing framework - - Write user journey tests - - Add test automation - - _Leverage: tests/helpers/testUtils.ts, tests/fixtures/data.ts_ - - _Requirements: All_ - - _Prompt: Role: QA Automation Engineer with expertise in E2E testing and test frameworks like Cypress or Playwright | Task: Implement comprehensive end-to-end tests covering all requirements, setting up testing framework and user journey tests using test utilities and fixtures | Restrictions: Must test real user workflows, ensure tests are maintainable and reliable, do not test implementation details | Success: E2E tests cover all critical user journeys, tests run reliably in CI/CD pipeline, user experience is validated from end-to-end_ - -- [ ] 6.2 Final integration and cleanup - - Integrate all components - - Fix any integration issues - - Clean up code and documentation - - _Leverage: src/utils/cleanup.ts, docs/templates/_ - - _Requirements: All_ - - _Prompt: Role: Senior Developer with expertise in code quality and system integration | Task: Complete final integration of all components and perform comprehensive cleanup covering all requirements, using cleanup utilities and documentation templates | Restrictions: Must not break existing functionality, ensure code quality standards are met, maintain documentation consistency | Success: All components are fully integrated and working together, code is clean and well-documented, system meets all requirements and quality standards_ diff --git a/.spec-workflow/templates/tech-template.md b/.spec-workflow/templates/tech-template.md deleted file mode 100644 index 57cd538d..00000000 --- a/.spec-workflow/templates/tech-template.md +++ /dev/null @@ -1,99 +0,0 @@ -# Technology Stack - -## Project Type -[Describe what kind of project this is: web application, CLI tool, desktop application, mobile app, library, API service, embedded system, game, etc.] - -## Core Technologies - -### Primary Language(s) -- **Language**: [e.g., Python 3.11, Go 1.21, TypeScript, Rust, C++] -- **Runtime/Compiler**: [if applicable] -- **Language-specific tools**: [package managers, build tools, etc.] - -### Key Dependencies/Libraries -[List the main libraries and frameworks your project depends on] -- **[Library/Framework name]**: [Purpose and version] -- **[Library/Framework name]**: [Purpose and version] - -### Application Architecture -[Describe how your application is structured - this could be MVC, event-driven, plugin-based, client-server, standalone, microservices, monolithic, etc.] - -### Data Storage (if applicable) -- **Primary storage**: [e.g., PostgreSQL, files, in-memory, cloud storage] -- **Caching**: [e.g., Redis, in-memory, disk cache] -- **Data formats**: [e.g., JSON, Protocol Buffers, XML, binary] - -### External Integrations (if applicable) -- **APIs**: [External services you integrate with] -- **Protocols**: [e.g., HTTP/REST, gRPC, WebSocket, TCP/IP] -- **Authentication**: [e.g., OAuth, API keys, certificates] - -### Monitoring & Dashboard Technologies (if applicable) -- **Dashboard Framework**: [e.g., React, Vue, vanilla JS, terminal UI] -- **Real-time Communication**: [e.g., WebSocket, Server-Sent Events, polling] -- **Visualization Libraries**: [e.g., Chart.js, D3, terminal graphs] -- **State Management**: [e.g., Redux, Vuex, file system as source of truth] - -## Development Environment - -### Build & Development Tools -- **Build System**: [e.g., Make, CMake, Gradle, npm scripts, cargo] -- **Package Management**: [e.g., pip, npm, cargo, go mod, apt, brew] -- **Development workflow**: [e.g., hot reload, watch mode, REPL] - -### Code Quality Tools -- **Static Analysis**: [Tools for code quality and correctness] -- **Formatting**: [Code style enforcement tools] -- **Testing Framework**: [Unit, integration, and/or end-to-end testing tools] -- **Documentation**: [Documentation generation tools] - -### Version Control & Collaboration -- **VCS**: [e.g., Git, Mercurial, SVN] -- **Branching Strategy**: [e.g., Git Flow, GitHub Flow, trunk-based] -- **Code Review Process**: [How code reviews are conducted] - -### Dashboard Development (if applicable) -- **Live Reload**: [e.g., Hot module replacement, file watchers] -- **Port Management**: [e.g., Dynamic allocation, configurable ports] -- **Multi-Instance Support**: [e.g., Running multiple dashboards simultaneously] - -## Deployment & Distribution (if applicable) -- **Target Platform(s)**: [Where/how the project runs: cloud, on-premise, desktop, mobile, embedded] -- **Distribution Method**: [How users get your software: download, package manager, app store, SaaS] -- **Installation Requirements**: [Prerequisites, system requirements] -- **Update Mechanism**: [How updates are delivered] - -## Technical Requirements & Constraints - -### Performance Requirements -- [e.g., response time, throughput, memory usage, startup time] -- [Specific benchmarks or targets] - -### Compatibility Requirements -- **Platform Support**: [Operating systems, architectures, versions] -- **Dependency Versions**: [Minimum/maximum versions of dependencies] -- **Standards Compliance**: [Industry standards, protocols, specifications] - -### Security & Compliance -- **Security Requirements**: [Authentication, encryption, data protection] -- **Compliance Standards**: [GDPR, HIPAA, SOC2, etc. if applicable] -- **Threat Model**: [Key security considerations] - -### Scalability & Reliability -- **Expected Load**: [Users, requests, data volume] -- **Availability Requirements**: [Uptime targets, disaster recovery] -- **Growth Projections**: [How the system needs to scale] - -## Technical Decisions & Rationale -[Document key architectural and technology choices] - -### Decision Log -1. **[Technology/Pattern Choice]**: [Why this was chosen, alternatives considered] -2. **[Architecture Decision]**: [Rationale, trade-offs accepted] -3. **[Tool/Library Selection]**: [Reasoning, evaluation criteria] - -## Known Limitations -[Document any technical debt, limitations, or areas for improvement] - -- [Limitation 1]: [Impact and potential future solutions] -- [Limitation 2]: [Why it exists and when it might be addressed] diff --git a/.spec-workflow/user-templates/README.md b/.spec-workflow/user-templates/README.md deleted file mode 100644 index ad36a48b..00000000 --- a/.spec-workflow/user-templates/README.md +++ /dev/null @@ -1,64 +0,0 @@ -# User Templates - -This directory allows you to create custom templates that override the default Spec Workflow templates. - -## How to Use Custom Templates - -1. **Create your custom template file** in this directory with the exact same name as the default template you want to override: - - `requirements-template.md` - Override requirements document template - - `design-template.md` - Override design document template - - `tasks-template.md` - Override tasks document template - - `product-template.md` - Override product steering template - - `tech-template.md` - Override tech steering template - - `structure-template.md` - Override structure steering template - -2. **Template Loading Priority**: - - The system first checks this `user-templates/` directory - - If a matching template is found here, it will be used - - Otherwise, the default template from `templates/` will be used - -## Example Custom Template - -To create a custom requirements template: - -1. Create a file named `requirements-template.md` in this directory -2. Add your custom structure, for example: - -```markdown -# Requirements Document - -## Executive Summary -[Your custom section] - -## Business Requirements -[Your custom structure] - -## Technical Requirements -[Your custom fields] - -## Custom Sections -[Add any sections specific to your workflow] -``` - -## Template Variables - -Templates can include placeholders that will be replaced when documents are created: -- `{{projectName}}` - The name of your project -- `{{featureName}}` - The name of the feature being specified -- `{{date}}` - The current date -- `{{author}}` - The document author - -## Best Practices - -1. **Start from defaults**: Copy a default template from `../templates/` as a starting point -2. **Keep structure consistent**: Maintain similar section headers for tool compatibility -3. **Document changes**: Add comments explaining why sections were added/modified -4. **Version control**: Track your custom templates in version control -5. **Test thoroughly**: Ensure custom templates work with the spec workflow tools - -## Notes - -- Custom templates are project-specific and not included in the package distribution -- The `templates/` directory contains the default templates which are updated with each version -- Your custom templates in this directory are preserved during updates -- If a custom template has errors, the system will fall back to the default template From 6372ecdb2446c91fb10edf91e4877c9d80075344 Mon Sep 17 00:00:00 2001 From: ciddwd <2117971372@qq.com> Date: Wed, 10 Jun 2026 11:59:51 +0800 Subject: [PATCH 5/8] =?UTF-8?q?fix(remote-input):=20=E8=90=BD=E5=AE=9E=20r?= =?UTF-8?q?eview=20=E4=B8=89=E9=A1=B9=E5=BB=BA=E8=AE=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 远程标志清理收敛(修真 bug):Starting 阶段 stop 走 pending_stop 延迟 end_session 后无人清 remote_source_active,残留 true 会让下一次本地听写 误走远程分支(跳过 cpal、等不到手机 PCM)。抽 clear_remote_source_flags 并补到 pending_stop 收尾与 cancel_session(幂等,本地会话 no-op)。 2. PIN 防爆破改按源 IP 锁定:pin_fails 改 HashMap,accept loop 经 axum Extension 注入 peer IP;带容量上限 + 过期清理,防伪造海量源 IP 撑爆。 3. 证书信任警告:mobileconfig 处注释说明安全边界(证书为非 CA 纯服务器证书, 无签发能力);设置页新增 certTrustWarning 文案(5 语言)。 验证:cargo check 通过;cargo test --lib coordinator 相关全过(asr mock 5 例 失败为基线既有,与本改动无关);tsc 通过。 --- openless-all/app/src-tauri/src/coordinator.rs | 15 +++-- .../src/coordinator/dictation_session.rs | 6 ++ .../app/src-tauri/src/remote_server/mod.rs | 59 +++++++++++++------ openless-all/app/src/i18n/en.ts | 2 + openless-all/app/src/i18n/ja.ts | 2 + openless-all/app/src/i18n/ko.ts | 2 + openless-all/app/src/i18n/zh-CN.ts | 2 + openless-all/app/src/i18n/zh-TW.ts | 2 + .../src/pages/settings/RemoteInputSection.tsx | 2 + 9 files changed, 70 insertions(+), 22 deletions(-) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index c8a5fd8e..6d5e2aef 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -1051,10 +1051,7 @@ impl Coordinator { } fn clear_remote_source(&self) { - self.inner - .remote_source_active - .store(false, Ordering::SeqCst); - *self.inner.remote_audio_sink.lock() = None; + clear_remote_source_flags(&self.inner); } /// 当前远程输入运行态(供命令/前端查询)。 @@ -1355,3 +1352,13 @@ fn set_phase_idle_if_session_matches(inner: &Arc, session_id: SessionId) state.phase = SessionPhase::Idle; } } + +/// 清远程音频源标志(幂等)。必须在远程会话生命周期的**每个**终结点调用: +/// 残留的 `remote_source_active=true` 会让下一次本地听写误走远程分支 +/// (跳过 cpal、挂上 sink 等手机 PCM),本地录音从此失效。 +/// 终结点:stop/cancel_remote_dictation、start 失败回滚、cancel_session、 +/// pending_stop 的延迟 end_session(finish_starting_session)。 +pub(crate) fn clear_remote_source_flags(inner: &Inner) { + inner.remote_source_active.store(false, Ordering::SeqCst); + *inner.remote_audio_sink.lock() = None; +} diff --git a/openless-all/app/src-tauri/src/coordinator/dictation_session.rs b/openless-all/app/src-tauri/src/coordinator/dictation_session.rs index b0e5428d..458f999f 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation_session.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation_session.rs @@ -632,6 +632,9 @@ pub(crate) async fn finish_starting_session(inner: &Arc, session_id: Sess if matches!(outcome, BeginOutcome::PendingStop) { log::info!("[coord] applying pending_stop edge → end_session immediately"); let _ = end_session(inner).await; + // 远程会话经 pending_stop 收尾时,stop_remote_dictation 已经早退, + // 不会再有人清远程标志 —— 在这里兜底(本地会话下是 no-op)。 + clear_remote_source_flags(inner); } } } @@ -673,6 +676,9 @@ pub(crate) fn cancel_session(inner: &Arc) { stop_recorder_for_session(inner, decision.session_id); cancel_asr_for_session(inner, decision.session_id); + // 远程会话被取消(含本地 Esc / 错误路径触发的 cancel)时同步清远程标志, + // 避免 remote_source_active 残留把下一次本地听写错引到远程分支。 + clear_remote_source_flags(inner); restore_prepared_windows_ime_session(inner, decision.session_id); // Processing 阶段保持 phase=Processing 让 end_session 自己走完检查 + 收尾; // 其他阶段直接转 Idle。 diff --git a/openless-all/app/src-tauri/src/remote_server/mod.rs b/openless-all/app/src-tauri/src/remote_server/mod.rs index d64661b1..93a6e72f 100644 --- a/openless-all/app/src-tauri/src/remote_server/mod.rs +++ b/openless-all/app/src-tauri/src/remote_server/mod.rs @@ -43,9 +43,13 @@ const HEADER_HTML: &str = "text/html; charset=utf-8"; const HEADER_JS: &str = "application/javascript; charset=utf-8"; const HEADER_CSS: &str = "text/css; charset=utf-8"; -/// 同一来源连续输错 PIN 的锁定阈值与时长。 +/// 同一来源 IP 连续输错 PIN 的锁定阈值与时长。按 IP 而非全局计数:全局锁会被 +/// 局域网内多台机器分摊(每台只贡献几次失败就触发全局锁,反而 DoS 正常用户); +/// 按 IP 则每个攻击源各自被限到 ~5 次/分钟,10^6 个 PIN 组合在锁定节奏下不可行。 const PIN_MAX_FAILS: u32 = 5; const PIN_LOCK_SECS: u64 = 60; +/// pin_fails 表的容量上限:超过即清理已过期/已解锁的条目,防止伪造海量源 IP 撑爆内存。 +const PIN_FAILS_MAX_ENTRIES: usize = 256; // ───────────────────────── 对外类型 ───────────────────────── @@ -252,12 +256,17 @@ struct WsState { pin: String, coordinator: Arc, app: AppHandle, - /// 全局 PIN 失败计数 + 锁定截止时刻(简单防爆破;TLS+6 位 PIN 已是主防线)。 - pin_fails: Mutex<(u32, Option)>, + /// 按源 IP 的 PIN 失败计数 + 锁定截止时刻(防爆破;TLS+6 位 PIN 已是主防线)。 + pin_fails: Mutex)>>, /// 自签名证书的 DER 原始字节,供 /cert.cer 下载给手机安装信任。 cert_der: Vec, } +/// 经 accept loop 注入的对端 IP(axum Extension)。hyper 直连 TLS 流时拿不到 +/// ConnectInfo,这里在每条连接的 service 上挂一层 Extension 把 peer 传进 handler。 +#[derive(Clone, Copy)] +struct PeerIp(IpAddr); + fn build_router(state: Arc) -> Router { Router::new() .route("/", get(index_handler)) @@ -339,6 +348,12 @@ fn base64_encode(data: &[u8]) -> String { /// iOS 配置描述文件:把证书包成 .mobileconfig。Safari 点击后凭 content-type /// (application/x-apple-aspen-config) 直接进入“安装描述文件”流程,比裸 .cer 顺滑、 /// 也不会把当前页面导航走。安装后仍需到「设置→通用→关于本机→证书信任设置」打开完全信任。 +/// +/// 安全边界:PayloadType `com.apple.security.root` 只是 iOS 安装证书的固定入口, +/// 证书本身是非 CA 的纯服务器证书(rcgen NoCa + EKU=ServerAuth,见 +/// load_or_generate_cert)——不含签发能力,无法用来给其他域名签证书做 MITM。 +/// 信任它的影响范围仅限「持有本机私钥者可冒充 SAN 里列出的本机局域网 IP」, +/// 私钥只存在用户 PC 的应用配置目录。设置页 certTrustWarning 同步向用户说明。 async fn mobileconfig_handler(State(state): State>) -> impl IntoResponse { let b64 = base64_encode(&state.cert_der); let xml = format!( @@ -382,7 +397,7 @@ pub async fn start(cfg: RemoteServerConfig) -> Result Result t, @@ -439,12 +455,13 @@ pub async fn start(cfg: RemoteServerConfig) -> Result>, + axum::Extension(PeerIp(peer_ip)): axum::Extension, ws: WebSocketUpgrade, ) -> impl IntoResponse { // 能走到这里说明 wss 的 TLS 握手已成功(证书被手机接受)。排查"连不上"时看有没有 // 这行:没有 = 卡在 TLS/证书(握手就失败);有 = 握手 OK,问题在认证/后续逻辑。 log::info!("[remote-input] WS 已升级:手机已通过 wss 接入(TLS/证书 OK)"); - ws.on_upgrade(move |socket| handle_ws(socket, state)) + ws.on_upgrade(move |socket| handle_ws(socket, state, peer_ip)) } fn send_json(value: &T) -> Message { @@ -492,10 +509,10 @@ fn capsule_payload_to_phone(payload: &str) -> Vec { out } -async fn handle_ws(mut socket: WebSocket, state: Arc) { +async fn handle_ws(mut socket: WebSocket, state: Arc, peer_ip: IpAddr) { // 1) 握手:等第一帧 hello + PIN。 let authed = match tokio::time::timeout(Duration::from_secs(15), socket.recv()).await { - Ok(Some(Ok(Message::Text(txt)))) => verify_hello(&txt, &state), + Ok(Some(Ok(Message::Text(txt)))) => verify_hello(&txt, &state, peer_ip), _ => return, // 超时 / 非文本首帧 / 断开 }; match authed { @@ -623,16 +640,16 @@ enum AuthResult { Locked, } -fn verify_hello(txt: &str, state: &Arc) -> AuthResult { - // 锁定检查 +fn verify_hello(txt: &str, state: &Arc, peer_ip: IpAddr) -> AuthResult { + // 锁定检查(按源 IP) { let mut guard = state.pin_fails.lock(); - if let Some(until) = guard.1 { - if Instant::now() < until { + if let Some((_, Some(until))) = guard.get(&peer_ip) { + if Instant::now() < *until { return AuthResult::Locked; } - // 锁定到期,重置 - *guard = (0, None); + // 锁定到期,重置该 IP + guard.remove(&peer_ip); } } let v: serde_json::Value = match serde_json::from_str(txt) { @@ -645,13 +662,19 @@ fn verify_hello(txt: &str, state: &Arc) -> AuthResult { .map(|p| constant_time_eq(p.as_bytes(), state.pin.as_bytes())) .unwrap_or(false); if ok { - *state.pin_fails.lock() = (0, None); + state.pin_fails.lock().remove(&peer_ip); AuthResult::Ok } else { + let now = Instant::now(); let mut guard = state.pin_fails.lock(); - guard.0 += 1; - if guard.0 >= PIN_MAX_FAILS { - guard.1 = Some(Instant::now() + Duration::from_secs(PIN_LOCK_SECS)); + // 容量兜底:先丢已解锁/过期的条目,防伪造海量源 IP 撑爆表。 + if guard.len() >= PIN_FAILS_MAX_ENTRIES { + guard.retain(|_, (_, until)| matches!(until, Some(t) if *t > now)); + } + let entry = guard.entry(peer_ip).or_insert((0, None)); + entry.0 += 1; + if entry.0 >= PIN_MAX_FAILS { + entry.1 = Some(now + Duration::from_secs(PIN_LOCK_SECS)); } AuthResult::BadPin } diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 093ebe4a..ee6d9eb0 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -866,6 +866,8 @@ export const en: typeof zhCN = { portInUse: 'Port {{port}} is in use, please change it', securityHint: 'Reachable only on the same LAN and requires the pairing code; turn it off when not in use.', certHint: 'On first visit the browser warns the certificate is untrusted — choose "Proceed".', + certTrustWarning: + 'The certificate is only used by this PC’s remote input service (it cannot issue other certificates). Never trust certificates from unknown sources; remove it from your phone’s settings when no longer needed.', }, about: { tagline: 'Speak naturally, write perfectly', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index 1dcfaa6b..878cef41 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -868,6 +868,8 @@ export const ja: typeof zhCN = { portInUse: 'ポート {{port}} は使用中です。変更してください', securityHint: '同一 LAN からのみアクセス可能で、ペアリングコードの入力が必要です。使わないときはオフにすることを推奨します。', certHint: '初回アクセス時、ブラウザが証明書は信頼されていないと警告します。案内に従って「続行」を選択してください。', + certTrustWarning: + 'この証明書は本機のリモート入力サービス専用です(他の証明書を発行できません)。出所不明の証明書は信頼しないでください。不要になったらスマートフォンの設定から削除できます。', }, about: { tagline: '自然に話し、きれいに書く', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index 9a2ce786..fee50c57 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -868,6 +868,8 @@ export const ko: typeof zhCN = { portInUse: '포트 {{port}}이(가) 사용 중입니다. 변경하세요', securityHint: '같은 LAN에서만 접속 가능하며 페어링 코드 입력이 필요합니다. 사용하지 않을 때는 끄는 것을 권장합니다.', certHint: '첫 접속 시 브라우저가 인증서를 신뢰할 수 없다고 경고합니다. 안내에 따라 "계속 진행"을 선택하세요.', + certTrustWarning: + '이 인증서는 이 PC의 원격 입력 서비스 전용입니다(다른 인증서를 발급할 수 없음). 출처를 알 수 없는 인증서는 신뢰하지 마세요. 더 이상 사용하지 않으면 휴대폰 설정에서 제거할 수 있습니다.', }, about: { tagline: '자연스럽게 말하고, 정확하게 작성하세요', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 77140a99..2545e9d5 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -864,6 +864,8 @@ export const zhCN = { portInUse: '端口 {{port}} 被占用,请更换', securityHint: '仅同一局域网可访问,需输入配对码;不用时建议关闭。', certHint: '首次访问浏览器会提示证书不受信任,按提示选择"继续访问"。', + certTrustWarning: + '该证书仅用于本机远程输入服务(不能签发其他证书),请勿信任来源不明的证书;不再使用时可在手机系统设置中移除。', }, about: { tagline: '自然说话,完美书写', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index ac2b7fed..5cbfd7bc 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -866,6 +866,8 @@ export const zhTW: typeof zhCN = { portInUse: '連接埠 {{port}} 被佔用,請更換', securityHint: '僅同一區域網路可存取,需輸入配對碼;不用時建議關閉。', certHint: '首次存取瀏覽器會提示憑證不受信任,按提示選擇「繼續存取」。', + certTrustWarning: + '該憑證僅用於本機遠端輸入服務(無法簽發其他憑證),請勿信任來源不明的憑證;不再使用時可在手機系統設定中移除。', }, about: { tagline: '自然說話,完美書寫', diff --git a/openless-all/app/src/pages/settings/RemoteInputSection.tsx b/openless-all/app/src/pages/settings/RemoteInputSection.tsx index b549032f..9397bcae 100644 --- a/openless-all/app/src/pages/settings/RemoteInputSection.tsx +++ b/openless-all/app/src/pages/settings/RemoteInputSection.tsx @@ -232,6 +232,8 @@ export function RemoteInputSection() { {t('settings.remoteInput.securityHint')}
{t('settings.remoteInput.certHint')} +
+ {t('settings.remoteInput.certTrustWarning')}
); From d3f2e33054729d17f68bf3c30307063ff1b63002 Mon Sep 17 00:00:00 2001 From: ciddwd <2117971372@qq.com> Date: Wed, 10 Jun 2026 14:50:04 +0800 Subject: [PATCH 6/8] =?UTF-8?q?fix(remote-input):=20verify=5Fhello=20?= =?UTF-8?q?=E9=94=81=E5=AE=9A=E6=A3=80=E6=9F=A5=E4=B8=8E=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E7=B4=AF=E8=AE=A1=E5=90=88=E5=B9=B6=E5=88=B0=E5=90=8C=E4=B8=80?= =?UTF-8?q?=E4=B8=B4=E7=95=8C=E5=8C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 之前分两次拿锁:同一 IP 的并发握手可以都先通过锁定检查、再各自累计失败, 让计数越过 PIN_MAX_FAILS 却不触发锁定。合并后并发请求无法在检查与累计之间 穿插。PIN 比较本身无共享状态,留在锁外。 另:cancel_remote_dictation 补注释说明 double-cancel(stop 收尾后断连)下 cancel_session 经 begin_cancel_session_state 返回 None 早退,是安全 no-op (review 提到的 panic 不存在)。 验证:cargo check 通过。 --- openless-all/app/src-tauri/src/coordinator.rs | 2 ++ .../app/src-tauri/src/remote_server/mod.rs | 34 +++++++++---------- 2 files changed, 19 insertions(+), 17 deletions(-) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 6d5e2aef..64099eaf 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -1045,6 +1045,8 @@ impl Coordinator { } /// 手机断连 / 点取消:丢弃本次,不落字。 + /// stop 正常收尾后的断连也会走到这里(double-cancel):此时会话已 Idle, + /// cancel_session 内部 begin_cancel_session_state 返回 None 早退,安全 no-op。 pub fn cancel_remote_dictation(&self) { cancel_session(&self.inner); self.clear_remote_source(); diff --git a/openless-all/app/src-tauri/src/remote_server/mod.rs b/openless-all/app/src-tauri/src/remote_server/mod.rs index 93a6e72f..9884027c 100644 --- a/openless-all/app/src-tauri/src/remote_server/mod.rs +++ b/openless-all/app/src-tauri/src/remote_server/mod.rs @@ -641,32 +641,32 @@ enum AuthResult { } fn verify_hello(txt: &str, state: &Arc, peer_ip: IpAddr) -> AuthResult { - // 锁定检查(按源 IP) - { - let mut guard = state.pin_fails.lock(); - if let Some((_, Some(until))) = guard.get(&peer_ip) { - if Instant::now() < *until { - return AuthResult::Locked; - } - // 锁定到期,重置该 IP - guard.remove(&peer_ip); - } - } + // PIN 比较在锁外完成(无共享状态;constant_time_eq 防计时侧信道)。 let v: serde_json::Value = match serde_json::from_str(txt) { Ok(v) => v, - Err(_) => return AuthResult::BadPin, + Err(_) => serde_json::Value::Null, // 非法 JSON 按 BadPin 计数 }; - let ok = v.get("type").and_then(|t| t.as_str()) == Some("hello") + let pin_ok = v.get("type").and_then(|t| t.as_str()) == Some("hello") && v.get("pin") .and_then(|p| p.as_str()) .map(|p| constant_time_eq(p.as_bytes(), state.pin.as_bytes())) .unwrap_or(false); - if ok { - state.pin_fails.lock().remove(&peer_ip); + + // 锁定检查与失败累计放同一临界区:之前分两次拿锁,同一 IP 的并发握手可以 + // 都先通过锁定检查再各自累计失败,让计数越过阈值却不触发锁定。 + let now = Instant::now(); + let mut guard = state.pin_fails.lock(); + if let Some((_, Some(until))) = guard.get(&peer_ip) { + if now < *until { + return AuthResult::Locked; + } + // 锁定到期,重置该 IP + guard.remove(&peer_ip); + } + if pin_ok { + guard.remove(&peer_ip); AuthResult::Ok } else { - let now = Instant::now(); - let mut guard = state.pin_fails.lock(); // 容量兜底:先丢已解锁/过期的条目,防伪造海量源 IP 撑爆表。 if guard.len() >= PIN_FAILS_MAX_ENTRIES { guard.retain(|_, (_, until)| matches!(until, Some(t) if *t > now)); From 23cc80ea23ad6410a3a99f56517f90ec9e74d273 Mon Sep 17 00:00:00 2001 From: ciddwd <2117971372@qq.com> Date: Wed, 10 Jun 2026 14:57:14 +0800 Subject: [PATCH 7/8] =?UTF-8?q?fix(remote-input):=20=E5=90=AF=E5=8A=A8?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E6=8C=89=20reason=20=E5=8C=BA=E5=88=86?= =?UTF-8?q?=E5=B1=95=E7=A4=BA=20+=20=E6=B8=85=E7=90=86=E8=AF=81=E4=B9=A6?= =?UTF-8?q?=E8=BF=87=E6=97=B6=E6=B3=A8=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 错误粒度:remote-input:error 携带的 reason 之前被前端忽略,任何启动失败 (TLS/权限/证书生成)都显示「端口被占用」误导排查。现在仅 reason == 'port-in-use' 时显示端口文案,其余显示通用 startError + 原始 reason (5 语言)。 2. load_or_generate_cert 残留 v3 时代「做成自签名 CA」的过时注释,与下方 v4「非 CA 服务器证书」的实现矛盾(review 机器人因此误判 iOS 不可用)。 统一为:主路径页面级例外,cert.cer/mobileconfig 是系统级安装兜底。 验证:cargo check 通过;tsc 通过。 --- .../app/src-tauri/src/remote_server/mod.rs | 6 +++--- openless-all/app/src/i18n/en.ts | 1 + openless-all/app/src/i18n/ja.ts | 1 + openless-all/app/src/i18n/ko.ts | 1 + openless-all/app/src/i18n/zh-CN.ts | 1 + openless-all/app/src/i18n/zh-TW.ts | 1 + .../app/src/pages/settings/RemoteInputSection.tsx | 14 ++++++++------ 7 files changed, 16 insertions(+), 9 deletions(-) diff --git a/openless-all/app/src-tauri/src/remote_server/mod.rs b/openless-all/app/src-tauri/src/remote_server/mod.rs index 9884027c..abd6dee5 100644 --- a/openless-all/app/src-tauri/src/remote_server/mod.rs +++ b/openless-all/app/src-tauri/src/remote_server/mod.rs @@ -193,9 +193,9 @@ fn load_or_generate_cert( } } } - // 生成自签名 CA 证书:iOS Safari / Android Chrome 的 wss 都不复用浏览器页面级的 - // “继续访问”例外,必须把证书装进系统并信任 —— 而系统的信任开关只对 CA 证书出现。 - // 所以做成自签名 CA(SAN 含本机各局域网 IP),用户在手机装一次并信任后 wss 才稳定。 + // 生成自签名服务器证书(SAN 含本机各局域网 IP)。主路径是浏览器页面级 + // “继续访问/访问此网站”例外;/cert.cer 与 /cert.mobileconfig 是手机系统级 + // 安装信任的兜底(部分浏览器的 wss 不复用页面级例外时使用)。 let (cert_der, key_der) = { use rcgen::{ CertificateParams, DistinguishedName, DnType, ExtendedKeyUsagePurpose, KeyPair, diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index ee6d9eb0..cc262ff2 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -864,6 +864,7 @@ export const en: typeof zhCN = { pinLabel: 'Pairing code', regeneratePin: 'Regenerate', portInUse: 'Port {{port}} is in use, please change it', + startError: 'Failed to start the remote input service: {{reason}}', securityHint: 'Reachable only on the same LAN and requires the pairing code; turn it off when not in use.', certHint: 'On first visit the browser warns the certificate is untrusted — choose "Proceed".', certTrustWarning: diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index 878cef41..bafa6309 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -866,6 +866,7 @@ export const ja: typeof zhCN = { pinLabel: 'ペアリングコード', regeneratePin: '再生成', portInUse: 'ポート {{port}} は使用中です。変更してください', + startError: 'リモート入力サービスの起動に失敗しました:{{reason}}', securityHint: '同一 LAN からのみアクセス可能で、ペアリングコードの入力が必要です。使わないときはオフにすることを推奨します。', certHint: '初回アクセス時、ブラウザが証明書は信頼されていないと警告します。案内に従って「続行」を選択してください。', certTrustWarning: diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index fee50c57..92122c12 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -866,6 +866,7 @@ export const ko: typeof zhCN = { pinLabel: '페어링 코드', regeneratePin: '재생성', portInUse: '포트 {{port}}이(가) 사용 중입니다. 변경하세요', + startError: '원격 입력 서비스 시작에 실패했습니다: {{reason}}', securityHint: '같은 LAN에서만 접속 가능하며 페어링 코드 입력이 필요합니다. 사용하지 않을 때는 끄는 것을 권장합니다.', certHint: '첫 접속 시 브라우저가 인증서를 신뢰할 수 없다고 경고합니다. 안내에 따라 "계속 진행"을 선택하세요.', certTrustWarning: diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 2545e9d5..6f5d4f3d 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -862,6 +862,7 @@ export const zhCN = { pinLabel: '配对码', regeneratePin: '重新生成', portInUse: '端口 {{port}} 被占用,请更换', + startError: '远程输入服务启动失败:{{reason}}', securityHint: '仅同一局域网可访问,需输入配对码;不用时建议关闭。', certHint: '首次访问浏览器会提示证书不受信任,按提示选择"继续访问"。', certTrustWarning: diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 5cbfd7bc..b825588e 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -864,6 +864,7 @@ export const zhTW: typeof zhCN = { pinLabel: '配對碼', regeneratePin: '重新產生', portInUse: '連接埠 {{port}} 被佔用,請更換', + startError: '遠端輸入服務啟動失敗:{{reason}}', securityHint: '僅同一區域網路可存取,需輸入配對碼;不用時建議關閉。', certHint: '首次存取瀏覽器會提示憑證不受信任,按提示選擇「繼續存取」。', certTrustWarning: diff --git a/openless-all/app/src/pages/settings/RemoteInputSection.tsx b/openless-all/app/src/pages/settings/RemoteInputSection.tsx index 9397bcae..b8bb2cf3 100644 --- a/openless-all/app/src/pages/settings/RemoteInputSection.tsx +++ b/openless-all/app/src/pages/settings/RemoteInputSection.tsx @@ -41,7 +41,7 @@ export function RemoteInputSection() { const { t, i18n } = useTranslation(); const { prefs, updatePrefs } = useHotkeySettings(); const [status, setStatus] = useState(null); - const [errorPort, setErrorPort] = useState(null); + const [startError, setStartError] = useState<{ reason: string; port: number } | null>(null); const [copied, setCopied] = useState(null); useEffect(() => { @@ -57,12 +57,12 @@ export function RemoteInputSection() { const unsubs: Array<() => void> = []; import('@tauri-apps/api/event').then(({ listen }) => { listen('remote-input:running', () => { - setErrorPort(null); + setStartError(null); refresh(); }).then((u) => unsubs.push(u)); listen('remote-input:error', (e) => { - const p = e.payload as { port?: number } | null; - if (alive) setErrorPort(p?.port ?? 0); + const p = e.payload as { reason?: string; port?: number } | null; + if (alive) setStartError({ reason: p?.reason ?? '', port: p?.port ?? 0 }); }).then((u) => unsubs.push(u)); }); return () => { @@ -215,9 +215,11 @@ export function RemoteInputSection() { )} - {enabled && errorPort != null && ( + {enabled && startError != null && (
- {t('settings.remoteInput.portInUse', { port: errorPort })} + {startError.reason === 'port-in-use' + ? t('settings.remoteInput.portInUse', { port: startError.port }) + : t('settings.remoteInput.startError', { reason: startError.reason })}
)} From 6e62009260f5eccb2ff352f38f1be214d1a7ac7e Mon Sep 17 00:00:00 2001 From: ciddwd <2117971372@qq.com> Date: Wed, 10 Jun 2026 15:19:09 +0800 Subject: [PATCH 8/8] =?UTF-8?q?fix(remote-input):=20=E5=85=A8=E9=9D=A2?= =?UTF-8?q?=E8=87=AA=E5=AE=A1=E2=80=94=E2=80=94=E4=BF=AE=E5=A4=8D=202=20?= =?UTF-8?q?=E4=B8=AA=20HIGH=20=E7=AB=9E=E6=80=81=E4=B8=8E=E4=B8=80?= =?UTF-8?q?=E6=89=B9=E4=B8=AD=E5=8D=B1=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按评审机器人的维度(安全/并发/资源/错误处理/边界)对远程输入全部改动面 做了一轮地毯式自审,修复如下: Rust 核心(2 HIGH + 3 medium): - [HIGH] start_remote_dictation TOCTOU:busy 预检查与 remote_source_active 置位原在锁外,竞态输家把残留标志泄给抢先启动的本地会话(被劫持进远程 分支:不开麦克风、听写全文经 remote:result 泄给手机)。新增 begin_session_with_source(remote),busy 判定与置位移入 begin_session_state 同一临界区(与本地路径同构);busy 时返回 Err(REMOTE_BUSY) 供手机回执。 - [HIGH] 服务关停/重置 PIN 不撤销存量连接:连接任务是裸 spawn,shutdown 只 停 accept loop,已配对手机仍能录音落字。新增 watch 关停广播,shutdown 时 断开全部存量 WS 连接(旧 handle 被覆盖时靠 sender drop 自动生效)。 - end_session 新增 RemoteFlagsJanitor(Drop 兜底):十余处终结路径统一在 会话回 Idle 时清远程标志;同时修复 double-stop 过早清标志导致「仅回传」 失效与 remote:result 丢失(phase 非 Idle 不清)。 - stop/cancel_remote_dictation 加守卫:手机 stop/断连不再能终止 PC 用户 正在进行的本地听写。 - refresh_remote_server 串行化(异步锁 + 代数):连点开关/连改端口不再交错 启动、误报 port-in-use;中间代直接让位只跑最后一轮。 - WS 主循环加 keepalive(30s Ping / 90s 无上行断开):手机息屏不发 FIN 时 半开连接与录音会话不再悬挂;PCM 单帧加 64KB 上限防认证后 DoS。 - 私钥 remote-key-v4.der 在 Unix 下收紧为 0600。 H5 录音页(4 medium + 1 low): - getUserMedia 超时后迟到 resolve 用代际计数作废,不再泄漏麦克风轨道; - hold 按下立即松手不再发孤立 stop(startSent 配对),transcribing/polishing 加 30s 客户端兜底超时,UI 不再可能永久卡「识别中…」; - iOS 来电/Siri 后的 'interrupted' 状态改为非 running 即 resume; - sessionStorage 被禁时写后读回校验,根除无限 reload 循环; - readyTimer/busyTimer 统一跟踪清理,杜绝迟到定时器错盖新状态。 设置页(2 medium + 1 low): - listen() 异步注册 vs 卸载竞态:注册完成若已卸载立即退订; - 端口输入改草稿+失焦提交:不再逐键重启服务,clamp 对齐 [1024,65535], 取整防 u16 反序列化失败; - 重置 PIN 用命令返回值本地更新,不再撞异步重启窗口闪烁,并加错误处理。 验证:cargo check 0 错误;cargo test --lib 508 过(5 例失败为基线既有 asr mock);tsc 通过;node --check app.js 通过。 --- openless-all/app/src-tauri/src/coordinator.rs | 61 ++++++++---- .../src/coordinator/dictation_end.rs | 20 ++++ .../src/coordinator/dictation_session.rs | 29 +++++- .../src-tauri/src/remote_server/assets/app.js | 96 +++++++++++++++++-- .../app/src-tauri/src/remote_server/mod.rs | 61 +++++++++++- .../src/pages/settings/RemoteInputSection.tsx | 65 +++++++++---- 6 files changed, 286 insertions(+), 46 deletions(-) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 64099eaf..6c19b141 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -9,7 +9,7 @@ //! insertion, persists history, emits `capsule:state` events to the capsule //! window. -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::mpsc; use std::sync::Arc; use std::time::Instant; @@ -257,6 +257,12 @@ pub(crate) struct Inner { remote_audio_sink: Mutex>>, /// 远程输入 HTTPS+WS 服务句柄。None = 未启动。 remote_server: Mutex>, + /// refresh_remote_server 的代数:每次调用自增,spawn 出的任务持自己的代数, + /// 持锁后发现已有更新代排队则直接让位(连点开关/连改端口只跑最后一轮)。 + remote_refresh_gen: AtomicU64, + /// 串行化「停旧 → 启新」全流程的异步锁。无串行化时两轮 refresh 可交错: + /// 后到者 take 到 None 跳过关停、去 bind 旧服务尚未释放的端口 → 误报 port-in-use。 + remote_refresh_lock: tokio::sync::Mutex<()>, /// 当前远程输入配对码(6 位数字)。进程内有效,不持久化(每次启动可轮换)。 remote_pin: Mutex>, /// PC 端当前界面语言(BCP-47,如 "zh-CN")。前端切换语言时经命令同步, @@ -343,6 +349,8 @@ impl Coordinator { remote_source_active: AtomicBool::new(false), remote_audio_sink: Mutex::new(None), remote_server: Mutex::new(None), + remote_refresh_gen: AtomicU64::new(0), + remote_refresh_lock: tokio::sync::Mutex::new(()), remote_pin: Mutex::new(None), remote_locale: Mutex::new(String::from("zh-CN")), remote_no_insert: AtomicBool::new(false), @@ -417,6 +425,8 @@ impl Coordinator { remote_source_active: AtomicBool::new(false), remote_audio_sink: Mutex::new(None), remote_server: Mutex::new(None), + remote_refresh_gen: AtomicU64::new(0), + remote_refresh_lock: tokio::sync::Mutex::new(()), remote_pin: Mutex::new(None), remote_locale: Mutex::new(String::from("zh-CN")), remote_no_insert: AtomicBool::new(false), @@ -1004,15 +1014,16 @@ impl Coordinator { } pub async fn start_remote_dictation(&self) -> Result<(), String> { - if !matches!(self.inner.state.lock().phase, SessionPhase::Idle) { - return Err("busy".into()); - } - self.inner - .remote_source_active - .store(true, Ordering::SeqCst); - let r = begin_session(&self.inner).await; - if r.is_err() { - self.clear_remote_source(); + // busy 判定与 remote_source_active 置位都在 begin_session_with_source 的 + // state 临界区内原子完成(与本地热键的 begin_session_state 同构)。之前是 + // 锁外预检查 + 锁外置位,竞态输家会把残留标志泄给抢先启动的本地会话。 + let r = begin_session_with_source(&self.inner, true).await; + if let Err(e) = &r { + // busy = 标志从未置位,不能清——清了会破坏正在进行的远程会话 + // (手机重复点「开始」就会走到这里)。置位之后的失败(ASR 凭据等)才回滚。 + if e != REMOTE_BUSY { + self.clear_remote_source(); + } } r } @@ -1033,21 +1044,31 @@ impl Coordinator { } /// 手机点"停止"。Starting 阶段记 pending_stop(等启动完成自动收尾);否则走 - /// end_session(转写→润色→光标落字,与本地一致),随后清 remote 标志。 + /// end_session(转写→润色→光标落字,与本地一致)。 + /// 远程标志的清理不在这里做:end_session 内的 RemoteFlagsJanitor 在会话真正 + /// 回到 Idle 时统一清。这里清会在 double-stop(第二次调用对 Processing 中的 + /// 在飞 end_session 早退后)把标志过早清掉——在飞调用读到 false 后, + /// 「仅回传」开关失效(文字落到 PC)且 remote:result 不再回传手机。 pub async fn stop_remote_dictation(&self) -> Result<(), String> { + // 守卫:当前会话不是远程发起的则忽略。否则手机的 stop 会终止 PC 用户 + // 正在进行的本地听写(stop/cancel 方向没有 busy 那样的天然互斥)。 + if !self.inner.remote_source_active.load(Ordering::SeqCst) { + return Ok(()); + } if self.inner.state.lock().phase == SessionPhase::Starting { request_stop_during_starting(&self.inner, "remote stop"); return Ok(()); } - let r = end_session(&self.inner).await; - self.clear_remote_source(); - r + end_session(&self.inner).await } /// 手机断连 / 点取消:丢弃本次,不落字。 - /// stop 正常收尾后的断连也会走到这里(double-cancel):此时会话已 Idle, - /// cancel_session 内部 begin_cancel_session_state 返回 None 早退,安全 no-op。 + /// 手机锁屏/切后台/Wi-Fi 抖动都会触发 WS 断连进而走到这里——守卫确保只 + /// 取消远程发起的会话,不误杀 PC 用户正在进行的本地听写。 pub fn cancel_remote_dictation(&self) { + if !self.inner.remote_source_active.load(Ordering::SeqCst) { + return; + } cancel_session(&self.inner); self.clear_remote_source(); } @@ -1108,7 +1129,15 @@ impl Coordinator { /// 按 prefs 启停 / 重启远程输入服务。在 setup 与 prefs 变更(端口/开关)时调用。 pub fn refresh_remote_server(self: &Arc) { let coord = Arc::clone(self); + let gen = self.inner.remote_refresh_gen.fetch_add(1, Ordering::SeqCst) + 1; tauri::async_runtime::spawn(async move { + // 串行化整个「停旧 → 启新」:并发的两轮 refresh 交错时,后到者会 take 到 + // None 跳过关停、去 bind 旧服务还没释放的端口 → 误报 port-in-use。 + let _serial = coord.inner.remote_refresh_lock.lock().await; + // 已有更新代排队(用户连点开关/连改端口):本代直接让位,只跑最后一轮。 + if coord.inner.remote_refresh_gen.load(Ordering::SeqCst) != gen { + return; + } // 先停旧(优雅关停) let old = coord.inner.remote_server.lock().take(); if let Some(handle) = old { diff --git a/openless-all/app/src-tauri/src/coordinator/dictation_end.rs b/openless-all/app/src-tauri/src/coordinator/dictation_end.rs index e094af40..7becc51a 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation_end.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation_end.rs @@ -6,7 +6,27 @@ use crate::correction::apply_correction_rules; use super::resources::*; use super::*; +/// 远程标志清道夫。end_session 的终结路径有十余处(正常收尾、ASR 失败/超时、 +/// 空转写、cancel 丢弃……),任何一处漏清 remote_source_active 都会把下一次本地 +/// 听写错引到远程分支(跳过 cpal、永远等不到手机 PCM)。逐点补调用维护不动, +/// 改用 Drop 统一兜底:end_session 以任何方式退出时,若会话已回 Idle 则清远程 +/// 标志(本地会话下是 no-op)。phase 非 Idle 时不清——比如 double-stop 的第二次 +/// 调用对着 Processing 中的在飞 end_session 早退,此刻清会让在飞调用读到 false: +/// 「仅回传」开关失效、remote:result 不回传。 +struct RemoteFlagsJanitor<'a> { + inner: &'a Arc, +} + +impl Drop for RemoteFlagsJanitor<'_> { + fn drop(&mut self) { + if self.inner.state.lock().phase == SessionPhase::Idle { + clear_remote_source_flags(self.inner); + } + } +} + pub(crate) async fn end_session(inner: &Arc) -> Result<(), String> { + let _remote_janitor = RemoteFlagsJanitor { inner }; let current_session_id = { let mut state = inner.state.lock(); let Some(session_id) = start_processing_if_listening(&mut state) else { diff --git a/openless-all/app/src-tauri/src/coordinator/dictation_session.rs b/openless-all/app/src-tauri/src/coordinator/dictation_session.rs index 458f999f..c99ae3b1 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation_session.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation_session.rs @@ -18,13 +18,36 @@ pub(crate) fn request_stop_during_starting(inner: &Arc, reason: &str) { } pub(crate) async fn begin_session(inner: &Arc) -> Result<(), String> { + begin_session_with_source(inner, false).await +} + +/// 远程会话被拒(busy)时的错误值。remote_server 据此给手机回 busy 提示; +/// start_remote_dictation 据此区分「未置位无需回滚」与「置位后失败需回滚」。 +pub(crate) const REMOTE_BUSY: &str = "busy"; + +/// `remote=true`:busy 时返回 `Err(REMOTE_BUSY)`(手机需要回执,不能像本地热键 +/// 那样静默吞掉);并且 `remote_source_active` 的置位发生在 Idle→Starting 转移的 +/// **同一临界区**内。之前是「预检查 → 锁外置位 → begin_session」三段式,本地热键 +/// 会话在窗口内抢先启动会读到残留的远程标志,被劫持进远程分支(不开麦克风、 +/// 听写全文经 remote:result 泄给手机)。 +pub(crate) async fn begin_session_with_source( + inner: &Arc, + remote: bool, +) -> Result<(), String> { let current_session_id = { let mut state = inner.state.lock(); let Some(session_id) = begin_session_state(&mut state, capture_focus_target(), capture_frontmost_app()) else { - return Ok(()); + return if remote { + Err(REMOTE_BUSY.into()) + } else { + Ok(()) + }; }; + if remote { + inner.remote_source_active.store(true, Ordering::SeqCst); + } if let Some(label) = state.front_app.as_deref() { log::info!("[coord] front_app captured: {label}"); } @@ -631,10 +654,8 @@ pub(crate) async fn finish_starting_session(inner: &Arc, session_id: Sess log::info!("[coord] session started"); if matches!(outcome, BeginOutcome::PendingStop) { log::info!("[coord] applying pending_stop edge → end_session immediately"); + // 远程标志的清理由 end_session 内的 RemoteFlagsJanitor 统一兜底。 let _ = end_session(inner).await; - // 远程会话经 pending_stop 收尾时,stop_remote_dictation 已经早退, - // 不会再有人清远程标志 —— 在这里兜底(本地会话下是 no-op)。 - clear_remote_source_flags(inner); } } } diff --git a/openless-all/app/src-tauri/src/remote_server/assets/app.js b/openless-all/app/src-tauri/src/remote_server/assets/app.js index ab8d1497..957ef25a 100644 --- a/openless-all/app/src-tauri/src/remote_server/assets/app.js +++ b/openless-all/app/src-tauri/src/remote_server/assets/app.js @@ -343,6 +343,7 @@ var ws = null; var authed = false; var recording = false; // 是否正在录音(决定是否 send 音频) + var startSent = false; // 本次录音的 {type:'start'} 是否已真正发出(等 ensureAudio 异步就绪后才发) var busy = false; // PC 端忙,本次禁用 var mode = readMode(); // 'toggle' | 'hold' var lastPin = ''; @@ -355,6 +356,10 @@ var scriptNode = null; var workletUrl = null; var usingWorklet = false; + // 音频代际计数:每次重置/释放音频时自增。getUserMedia 可能在 withTimeout 超时后 + // 迟到 resolve,若不校验代际,迟到的 stream 会泄漏活跃麦克风轨道,甚至覆盖丢失 + // 用户重试成功后的新流。 + var audioGen = 0; // ScriptProcessor 兜底用的重采样状态(跨块保留) var resampleState = { phase: 0, last: 0, hasLast: false }; @@ -495,6 +500,33 @@ if (!recording && authed) setStatus(L.ready, null); }, 2500); } + // 录音/停止/取消入口都要清掉 readyTimer,否则上一次 done 的回 ready 定时器会迟到 + // 触发,把"识别中…"等新状态错盖成"准备就绪"。 + function clearReadyTimer() { + if (readyTimer) { clearTimeout(readyTimer); readyTimer = null; } + } + + // busy 提示的解除定时器:跟踪起来,新状态到来时清除,避免多个 busy 消息叠加定时器 + // 或迟到的定时器覆盖新状态。 + var busyTimer = null; + + // 识别/润色阶段的客户端兜底超时:服务端任何原因不回 done/error(如孤立会话、进程异常) + // 时,30 秒后显示通用错误并回 ready,防止 UI 永久卡在"识别中…"。 + var workTimer = null; + function armWorkTimeout() { + clearWorkTimeout(); + workTimer = setTimeout(function () { + workTimer = null; + if (!recording && authed) { + setStatus('❌ ' + L.errGeneric, 'error'); + setLevel(0); + scheduleReady(); + } + }, 30000); + } + function clearWorkTimeout() { + if (workTimer) { clearTimeout(workTimer); workTimer = null; } + } // ============================================================ // WebSocket @@ -621,11 +653,14 @@ case 'busy': busy = true; recording = false; + startSent = false; // 本次会话被服务端拒绝,复位 start 标记 teardownAudioCapture(); // 停止采集但保留 ctx updateRecordBtnUI(); setStatus(fmt(L.busy, { reason: msg.reason || L.busyDefault }), 'error'); - // 短暂后解除忙态,允许重试 - setTimeout(function () { + // 短暂后解除忙态,允许重试。定时器存入 busyTimer 跟踪,重入时先清,避免叠加。 + if (busyTimer) clearTimeout(busyTimer); + busyTimer = setTimeout(function () { + busyTimer = null; busy = false; updateRecordBtnUI(); if (!recording) setStatus(L.ready, null); @@ -640,6 +675,12 @@ } function applyStatusKind(msg) { + // 真实状态到来即解除 busy 兜底定时,避免它迟到触发把新状态错盖成"准备就绪"。 + if (busyTimer) { + clearTimeout(busyTimer); busyTimer = null; + busy = false; + updateRecordBtnUI(); + } switch (msg.kind) { case 'recording': setStatus(stripLeadingIcon(L.statusRecording), 'work'); @@ -647,11 +688,14 @@ case 'transcribing': setStatus(stripLeadingIcon(L.statusTranscribing), 'work'); if (statusDots) statusDots.hidden = false; // 识别中:三点加载动效 + armWorkTimeout(); // 工作状态续上兜底超时,防止服务端中途无响应卡死 break; case 'polishing': setStatus(L.statusPolishing, 'work'); // 润色保留 ✨ + armWorkTimeout(); // 同上 break; case 'done': + clearWorkTimeout(); // 正常收尾,解除兜底超时 var n = (typeof msg.insertedChars === 'number') ? msg.insertedChars : 0; setStatus(stripLeadingIcon(fmt(L.statusDone, { n: n })), 'ok'); if (statusIcon) { statusIcon.src = '/done.png'; statusIcon.hidden = false; } // 完成:对勾图 @@ -659,6 +703,7 @@ scheduleReady(); break; case 'error': + clearWorkTimeout(); // 服务端已明确报错,解除兜底超时 setStatus('❌ ' + (msg.message || L.errGeneric), 'error'); setLevel(0); break; @@ -865,6 +910,9 @@ } // 先乐观置态,保证 iOS 在手势同步栈内 resume() recording = true; + startSent = false; // start 尚未真正发出(等 ensureAudio 异步完成后才发) + clearReadyTimer(); // 防止上一次 done 的回 ready 定时器迟到覆盖本次状态 + clearWorkTimeout(); // 新一次录音开始,作废上一轮的识别兜底超时 updateRecordBtnUI(); setStatus(L.preparingMic, 'work'); clearResult(); // 清掉上一次的识别结果,避免新录音时还显示旧文字 @@ -877,6 +925,7 @@ return; } wsSendJSON({ type: 'start' }); + startSent = true; // start 已发出,stopRecording 才需要配对发 stop setStatus(stripLeadingIcon(L.statusRecording), 'work'); }) .catch(function (err) { @@ -893,13 +942,23 @@ function stopRecording() { detachHoldEnd(); if (!recording) return; + clearReadyTimer(); // 防止迟到的回 ready 定时器覆盖"识别中…" recording = false; updateRecordBtnUI(); teardownAudioCapture(); + // start 还没发出(hold 按下后立即松手,ensureAudio 尚未完成)→ 按本地取消处理: + // 不发孤立 stop,否则 PC 无对应会话、不回 done/error,UI 会永久卡在"识别中…"。 + if (!startSent) { + setStatus(L.ready, null); + setLevel(0); + return; + } + startSent = false; wsSendJSON({ type: 'stop' }); setStatus(stripLeadingIcon(L.statusTranscribing), 'work'); if (statusDots) statusDots.hidden = false; setLevel(0); + armWorkTimeout(); // 兜底:30 秒内服务端不回 done/error 则强制回 ready } function cancelRecording() { @@ -909,10 +968,14 @@ teardownAudioCapture(); return; } + clearReadyTimer(); + clearWorkTimeout(); recording = false; updateRecordBtnUI(); teardownAudioCapture(); - wsSendJSON({ type: 'cancel' }); + // 同 stopRecording:start 未发出就不发孤立 cancel + if (startSent) wsSendJSON({ type: 'cancel' }); + startSent = false; setStatus(L.cancelled, null); setLevel(0); } @@ -951,7 +1014,9 @@ audioCtx = new AC(); } - var resumeP = (audioCtx.state === 'suspended') + // 注意:iOS Safari 来电/Siri 后 ctx 处于私有的 'interrupted' 状态,只判 'suspended' + // 不命中,会导致录音静默无声 —— 凡是非 running 都尝试 resume。 + var resumeP = (audioCtx.state !== 'running') ? audioCtx.resume().catch(function () {}) : Promise.resolve(); @@ -959,6 +1024,9 @@ .then(function () { // 2) 麦克风流(已存在则复用) if (mediaStream) return mediaStream; + // 捕获当前代际:迟到 resolve 时若代际已变(超时重置/断线释放),停掉轨道并放弃, + // 避免泄漏麦克风或覆盖重试成功的新流。 + var gen = audioGen; return navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1, @@ -968,6 +1036,10 @@ }, video: false }).then(function (stream) { + if (gen !== audioGen) { + try { stream.getTracks().forEach(function (t) { t.stop(); }); } catch (e) {} + return null; // 交给下一步判空直接放弃 + } mediaStream = stream; return stream; }); @@ -1201,6 +1273,7 @@ // 彻底释放(断线时):停止麦克风轨道并关闭 ctx。 function teardownAudio() { + audioGen++; // 代际推进:作废所有在途的 getUserMedia 迟到回调 teardownAudioCapture(); if (mediaStream) { try { @@ -1219,6 +1292,7 @@ // 与 teardownAudio 的区别:这里 close 并置空 audioCtx —— 超时根因往往是 ctx 自身坏掉 // (resume 永不 settle),保留它只会让下次继续卡。 function resetAudioContext() { + audioGen++; // 代际推进:作废所有在途的 getUserMedia 迟到回调 teardownAudioCapture(); if (mediaStream) { try { @@ -1254,9 +1328,17 @@ var reloadedOnce = false; try { reloadedOnce = sessionStorage.getItem('ol_reloaded_once') === '1'; } catch (e) {} if (!reloadedOnce) { - try { sessionStorage.setItem('ol_reloaded_once', '1'); } catch (e) {} - location.reload(); - return; + // 写后立即读回校验:sessionStorage 被禁用(写入抛异常/写不进去)时标记永远落不下, + // 若仍 reload 会无限循环刷新 —— 校验失败就放弃刷新,直接继续初始化。 + var marked = false; + try { + sessionStorage.setItem('ol_reloaded_once', '1'); + marked = sessionStorage.getItem('ol_reloaded_once') === '1'; + } catch (e) {} + if (marked) { + location.reload(); + return; + } } applyStaticI18n(); diff --git a/openless-all/app/src-tauri/src/remote_server/mod.rs b/openless-all/app/src-tauri/src/remote_server/mod.rs index abd6dee5..d6eb39c4 100644 --- a/openless-all/app/src-tauri/src/remote_server/mod.rs +++ b/openless-all/app/src-tauri/src/remote_server/mod.rs @@ -50,6 +50,15 @@ const PIN_MAX_FAILS: u32 = 5; const PIN_LOCK_SECS: u64 = 60; /// pin_fails 表的容量上限:超过即清理已过期/已解锁的条目,防止伪造海量源 IP 撑爆内存。 const PIN_FAILS_MAX_ENTRIES: usize = 256; +/// 单个 PCM 二进制帧的上限。16kHz/16bit 实时流正常每帧只有几 KB,64KB ≈ 2 秒音频; +/// 超限帧直接丢弃,防已配对客户端(或驱动它的恶意网页)推超大帧造成内存压力。 +const MAX_PCM_FRAME_BYTES: usize = 64 * 1024; +/// 服务端 keepalive:每 KEEPALIVE_PING_SECS 发一次 WS Ping(浏览器自动回 Pong); +/// 连续 IDLE_TIMEOUT_SECS 收不到任何上行帧(含 Pong)则视为半开死链断开。 +/// 手机息屏/Wi-Fi 漂移常常不发 TCP FIN,没有探活时 recv() 永久挂起:连接任务、 +/// 事件订阅、进行中的远程会话全部悬挂。 +const KEEPALIVE_PING_SECS: u64 = 30; +const IDLE_TIMEOUT_SECS: u64 = 90; // ───────────────────────── 对外类型 ───────────────────────── @@ -63,6 +72,10 @@ pub struct RemoteServerConfig { /// 运行中的服务句柄。drop / shutdown 触发优雅关停。 pub struct RemoteServerHandle { shutdown_tx: Option>, + /// 广播给所有已建立 WS 连接的关停信号。只停 accept loop 是不够的:连接任务 + /// 是独立 spawn 的,不通知它们的话,用户关掉远程输入(或重置 PIN 触发重启) + /// 后已配对的手机会话原样存活,仍能录音、向 PC 光标落字——撤销语义失效。 + conn_shutdown_tx: tokio::sync::watch::Sender, join: tauri::async_runtime::JoinHandle<()>, pub bound_port: u16, #[allow(dead_code)] @@ -70,11 +83,12 @@ pub struct RemoteServerHandle { } impl RemoteServerHandle { - /// 通知 accept loop 退出并等待其结束。 + /// 通知 accept loop 与所有存量 WS 连接退出,并等待 accept loop 结束。 pub async fn shutdown(mut self) { if let Some(tx) = self.shutdown_tx.take() { let _ = tx.send(()); } + let _ = self.conn_shutdown_tx.send(true); let _ = self.join.await; } } @@ -224,6 +238,17 @@ fn load_or_generate_cert( let _ = std::fs::create_dir_all(dir); let _ = std::fs::write(dir.join(CERT_FILE), &cert_der); let _ = std::fs::write(dir.join(KEY_FILE), &key_der); + // 私钥收紧为 0600:app 配置目录通常已是用户私有,但多用户/共享主机上 + // 默认 umask 可能给到组/其他用户可读。Windows 下 %APPDATA% 的 ACL + // 本身仅限本用户,无对应权限位可设。 + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions( + dir.join(KEY_FILE), + std::fs::Permissions::from_mode(0o600), + ); + } let _ = std::fs::write(dir.join(SANS_FILE), sans.join("\n")); log::info!("[remote-input] generated new self-signed server cert (SAN={sans:?})"); } @@ -260,6 +285,8 @@ struct WsState { pin_fails: Mutex)>>, /// 自签名证书的 DER 原始字节,供 /cert.cer 下载给手机安装信任。 cert_der: Vec, + /// 服务关停广播的接收端,每条 WS 连接 clone 一份并在主循环 select 监听。 + conn_shutdown_rx: tokio::sync::watch::Receiver, } /// 经 accept loop 注入的对端 IP(axum Extension)。hyper 直连 TLS 流时拿不到 @@ -393,12 +420,14 @@ pub async fn start(cfg: RemoteServerConfig) -> Result Result, peer_ip: IpAddr) }) }; - // 3) 主循环:手机上行(控制 / PCM) + 后端状态下行。 + // 3) 主循环:手机上行(控制 / PCM) + 后端状态下行 + keepalive 探活 + 关停广播。 + let mut conn_shutdown_rx = state.conn_shutdown_rx.clone(); + let mut keepalive = tokio::time::interval(Duration::from_secs(KEEPALIVE_PING_SECS)); + keepalive.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + let mut last_rx = Instant::now(); loop { tokio::select! { incoming = socket.recv() => { + last_rx = Instant::now(); match incoming { Some(Ok(Message::Binary(pcm))) => { - if pcm.len() >= 2 && pcm.len() % 2 == 0 { + if pcm.len() >= 2 && pcm.len() % 2 == 0 && pcm.len() <= MAX_PCM_FRAME_BYTES { state.coordinator.feed_remote_pcm(&pcm); } } @@ -587,6 +622,26 @@ async fn handle_ws(mut socket: WebSocket, state: Arc, peer_ip: IpAddr) break; } } + _ = keepalive.tick() => { + // 半开探活:浏览器收到 Ping 自动回 Pong(上面 recv 收到即刷新 last_rx)。 + // 超时无任何上行 → 死链,break 走下方统一收尾(cancel + unlisten), + // 避免录音中掉线时远程会话与标志悬挂。 + if last_rx.elapsed() > Duration::from_secs(IDLE_TIMEOUT_SECS) { + log::info!("[remote-input] 连接 {}s 无上行(含 Pong),按半开死链断开", IDLE_TIMEOUT_SECS); + break; + } + if socket.send(Message::Ping(Vec::new())).await.is_err() { + break; + } + } + changed = conn_shutdown_rx.changed() => { + // 服务关停(用户关闭远程输入 / 重置 PIN / 改端口触发重启): + // 主动断开存量连接,撤销已配对手机的会话与落字能力。 + if changed.is_err() || *conn_shutdown_rx.borrow() { + log::info!("[remote-input] 服务关停,断开存量手机连接"); + break; + } + } } } diff --git a/openless-all/app/src/pages/settings/RemoteInputSection.tsx b/openless-all/app/src/pages/settings/RemoteInputSection.tsx index b8bb2cf3..002a4c6c 100644 --- a/openless-all/app/src/pages/settings/RemoteInputSection.tsx +++ b/openless-all/app/src/pages/settings/RemoteInputSection.tsx @@ -43,6 +43,8 @@ export function RemoteInputSection() { const [status, setStatus] = useState(null); const [startError, setStartError] = useState<{ reason: string; port: number } | null>(null); const [copied, setCopied] = useState(null); + // 端口编辑草稿:失焦/回车时才解析提交,避免逐键持久化导致后端服务在中间值端口反复重启。 + const [portDraft, setPortDraft] = useState(null); useEffect(() => { let alive = true; @@ -57,13 +59,28 @@ export function RemoteInputSection() { const unsubs: Array<() => void> = []; import('@tauri-apps/api/event').then(({ listen }) => { listen('remote-input:running', () => { + if (!alive) return; setStartError(null); refresh(); - }).then((u) => unsubs.push(u)); + }).then((u) => { + // 异步注册完成时组件可能已卸载,立即退订避免监听器泄漏。 + if (!alive) { + u(); + } else { + unsubs.push(u); + } + }); listen('remote-input:error', (e) => { + if (!alive) return; const p = e.payload as { reason?: string; port?: number } | null; - if (alive) setStartError({ reason: p?.reason ?? '', port: p?.port ?? 0 }); - }).then((u) => unsubs.push(u)); + setStartError({ reason: p?.reason ?? '', port: p?.port ?? 0 }); + }).then((u) => { + if (!alive) { + u(); + } else { + unsubs.push(u); + } + }); }); return () => { alive = false; @@ -75,6 +92,21 @@ export function RemoteInputSection() { const enabled = prefs.remoteInputEnabled; const mode = prefs.remoteInputDefaultMode ?? 'toggle'; + // 提交端口草稿:非法(非有限数/越界离谱)则丢弃还原显示,合法则取整并 clamp 到 [1024, 65535]。 + const commitPort = () => { + if (portDraft == null) return; + const n = Math.round(Number(portDraft)); + if (!Number.isFinite(n) || n <= 0) { + setPortDraft(null); + return; + } + const port = Math.max(1024, Math.min(65535, n)); + setPortDraft(null); + if (port !== prefs.remoteInputPort) { + updatePrefs({ ...prefs, remoteInputPort: port }); + } + }; + const doCopy = async (url: string, pin: string) => { await copyText(`${url}\n${t('settings.remoteInput.pinLabel')}:${pin}`); setCopied(url); @@ -110,16 +142,12 @@ export function RemoteInputSection() { min={1024} max={65535} style={{ ...inputStyle, maxWidth: 140 }} - value={prefs.remoteInputPort} - onChange={(e) => - updatePrefs({ - ...prefs, - remoteInputPort: Math.max( - 1, - Math.min(65535, Number(e.currentTarget.value) || 0), - ), - }) - } + value={portDraft ?? String(prefs.remoteInputPort)} + onChange={(e) => setPortDraft(e.currentTarget.value)} + onBlur={commitPort} + onKeyDown={(e) => { + if (e.key === 'Enter') commitPort(); + }} /> @@ -202,9 +230,14 @@ export function RemoteInputSection() {