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] =?UTF-8?q?feat:=20=E6=89=8B=E6=9C=BA=E8=BF=9C=E7=A8=8B?= =?UTF-8?q?=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 ( <> +