feat: 手机远程录入
This commit is contained in:
1 parent
5ca3a80cd6
commit
a6f787d7a4
28 files changed
+3080
-14
No files matched your search
@@ -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]
|
||||
@@ -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]
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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_
|
||||
@@ -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]
|
||||
@@ -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
|
||||
Generated
+181
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<String> {
|
||||
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,
|
||||
|
||||
@@ -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<Option<Arc<dyn crate::recorder::AudioConsumer>>>,
|
||||
/// 远程输入 HTTPS+WS 服务句柄。None = 未启动。
|
||||
remote_server: Mutex<Option<crate::remote_server::RemoteServerHandle>>,
|
||||
/// 当前远程输入配对码(6 位数字)。进程内有效,不持久化(每次启动可轮换)。
|
||||
remote_pin: Mutex<Option<String>>,
|
||||
}
|
||||
|
||||
#[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<Self>) -> 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<Self>) {
|
||||
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 转发的备用触发路径。
|
||||
|
||||
@@ -585,20 +585,27 @@ pub(super) async fn begin_session(inner: &Arc<Inner>) -> 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<dyn crate::recorder::AudioConsumer>,
|
||||
) -> 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 最少间隔),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -0,0 +1,87 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="theme-color" content="#0f1115">
|
||||
<title>OpenLess 远程输入</title>
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<!-- ===== 首屏:PIN 配对 ===== -->
|
||||
<section id="screen-pin" class="screen active">
|
||||
<div class="brand">
|
||||
<div class="brand-logo">🎙️</div>
|
||||
<h1 class="brand-title">OpenLess 远程输入</h1>
|
||||
<p class="brand-sub">在手机上录音,实时输入到电脑</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<label class="field-label" for="pin-input">配对码(电脑上显示的 6 位数字)</label>
|
||||
<input
|
||||
id="pin-input"
|
||||
class="pin-input"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
autocomplete="off"
|
||||
maxlength="6"
|
||||
pattern="[0-9]*"
|
||||
placeholder="••••••" />
|
||||
<p id="pin-error" class="hint-error" hidden></p>
|
||||
<button id="btn-connect" class="btn btn-primary">连接</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== 录音屏 ===== -->
|
||||
<section id="screen-rec" class="screen">
|
||||
<header class="rec-header">
|
||||
<span class="conn-dot" id="conn-dot" title="已连接"></span>
|
||||
<span class="rec-header-title">OpenLess</span>
|
||||
<div class="mode-switch" id="mode-switch" role="group" aria-label="录音方式">
|
||||
<button class="mode-btn" data-mode="toggle" type="button">点按</button>
|
||||
<button class="mode-btn" data-mode="hold" type="button">按住</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="rec-main">
|
||||
<button id="btn-record" class="record-btn" type="button" aria-label="录音按钮">
|
||||
<span class="record-btn-ring"></span>
|
||||
<span class="record-btn-icon">🎤</span>
|
||||
<span class="record-btn-label" id="record-label">点击开始</span>
|
||||
</button>
|
||||
|
||||
<div class="level-wrap" aria-hidden="true">
|
||||
<div class="level-bar" id="level-bar"></div>
|
||||
</div>
|
||||
|
||||
<div class="status-bar" id="status-bar">
|
||||
<span class="status-text" id="status-text">准备就绪</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="rec-tip" id="rec-tip">点击大按钮开始录音,再次点击结束并识别。</p>
|
||||
</section>
|
||||
|
||||
<!-- ===== 断线屏 ===== -->
|
||||
<section id="screen-offline" class="screen">
|
||||
<div class="card card-center">
|
||||
<div class="offline-icon">📵</div>
|
||||
<h2 class="offline-title">连接已断开</h2>
|
||||
<p class="offline-sub" id="offline-reason">与电脑的连接已中断。</p>
|
||||
<button id="btn-reconnect" class="btn btn-primary">重新连接</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- 证书信任引导(固定底部) -->
|
||||
<footer class="cert-tip">
|
||||
首次访问浏览器会提示“连接不安全”(本地自签名证书)。
|
||||
Android Chrome:点“高级”→“继续前往”;
|
||||
iOS Safari:点“显示详情”→“访问此网站”。
|
||||
</footer>
|
||||
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -0,0 +1,439 @@
|
||||
//! 远程输入(局域网手机录音)的 HTTPS + WebSocket 服务。
|
||||
//!
|
||||
//! 手机在同一局域网用浏览器打开 `https://<PC-IP>:<port>`,得到一个录音页
|
||||
//! (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<Coordinator>,
|
||||
pub app: AppHandle,
|
||||
}
|
||||
|
||||
/// 运行中的服务句柄。drop / shutdown 触发优雅关停。
|
||||
pub struct RemoteServerHandle {
|
||||
shutdown_tx: Option<tokio::sync::oneshot::Sender<()>>,
|
||||
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<String>,
|
||||
}
|
||||
|
||||
// ───────────────────────── 工具函数 ─────────────────────────
|
||||
|
||||
/// 生成 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<Ipv4Addr> {
|
||||
let mut out: Vec<Ipv4Addr> = 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<String> {
|
||||
local_lan_ipv4s()
|
||||
.iter()
|
||||
.map(|ip| format!("https://{ip}:{port}"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ───────────────────────── TLS ─────────────────────────
|
||||
|
||||
fn build_rustls_config() -> Result<Arc<rustls::ServerConfig>, 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<Coordinator>,
|
||||
app: AppHandle,
|
||||
/// 全局 PIN 失败计数 + 锁定截止时刻(简单防爆破;TLS+6 位 PIN 已是主防线)。
|
||||
pin_fails: Mutex<(u32, Option<Instant>)>,
|
||||
}
|
||||
|
||||
fn build_router(state: Arc<WsState>) -> 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<RemoteServerHandle, String> {
|
||||
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<Arc<WsState>>,
|
||||
ws: WebSocketUpgrade,
|
||||
) -> impl IntoResponse {
|
||||
ws.on_upgrade(move |socket| handle_ws(socket, state))
|
||||
}
|
||||
|
||||
fn send_json<T: Serialize>(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<String> {
|
||||
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<WsState>) {
|
||||
// 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::<String>();
|
||||
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<WsState>, 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<WsState>) -> 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
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: 'アップデート確認',
|
||||
|
||||
@@ -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: '업데이트 확인',
|
||||
|
||||
@@ -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: '检查更新',
|
||||
|
||||
@@ -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: '檢查更新',
|
||||
|
||||
@@ -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<void> {
|
||||
})
|
||||
}
|
||||
|
||||
// ── Remote input (局域网手机录音) ──────────────────────────────────────
|
||||
export interface RemoteInputStatus {
|
||||
running: boolean
|
||||
port: number
|
||||
pin: string
|
||||
urls: string[]
|
||||
}
|
||||
|
||||
export function getRemoteInputStatus(): Promise<RemoteInputStatus> {
|
||||
return invokeOrMock("get_remote_input_status", undefined, () => ({
|
||||
running: false,
|
||||
port: 8443,
|
||||
pin: "000000",
|
||||
urls: [],
|
||||
}))
|
||||
}
|
||||
|
||||
export function listLocalIps(): Promise<string[]> {
|
||||
return invokeOrMock("list_local_ips", undefined, () => ["192.168.1.100"])
|
||||
}
|
||||
|
||||
export function regenerateRemotePin(): Promise<string> {
|
||||
return invokeOrMock("regenerate_remote_pin", undefined, () => "123456")
|
||||
}
|
||||
|
||||
// ── Release channel (Beta opt-in) ──────────────────────────────────────
|
||||
// 渠道偏好与 fetch_latest_beta_release 实际效果只在 Tauri runtime 内有意义;
|
||||
// 浏览器开发模式下走 mock,避免设置页因 invoke 抛错而白屏。
|
||||
|
||||
@@ -72,6 +72,10 @@ const previousPrefs: UserPreferences = {
|
||||
audioRecordingMaxEntries: null,
|
||||
marketplaceBaseUrl: '',
|
||||
marketplaceDevLogin: '',
|
||||
remoteInputEnabled: false,
|
||||
remoteInputPort: 8443,
|
||||
remoteInputPin: '000000',
|
||||
remoteInputDefaultMode: 'toggle',
|
||||
};
|
||||
|
||||
const nextPrefs: UserPreferences = {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<void> {
|
||||
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<RemoteInputStatus | null>(null);
|
||||
const [errorPort, setErrorPort] = useState<number | null>(null);
|
||||
const [copied, setCopied] = useState<string | null>(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 (
|
||||
<Collapsible title={t('settings.remoteInput.title')}>
|
||||
<SettingRow
|
||||
label={t('settings.remoteInput.enableLabel')}
|
||||
desc={t('settings.remoteInput.enableDesc')}
|
||||
>
|
||||
<Toggle
|
||||
on={enabled}
|
||||
onToggle={(v) => updatePrefs({ ...prefs, remoteInputEnabled: v })}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow label={t('settings.remoteInput.portLabel')}>
|
||||
<input
|
||||
type="number"
|
||||
min={1024}
|
||||
max={65535}
|
||||
style={{ ...inputStyle, maxWidth: 140 }}
|
||||
value={prefs.remoteInputPort}
|
||||
onChange={(e) =>
|
||||
updatePrefs({
|
||||
...prefs,
|
||||
remoteInputPort: Math.max(
|
||||
1,
|
||||
Math.min(65535, Number(e.currentTarget.value) || 0),
|
||||
),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow label={t('settings.remoteInput.defaultModeLabel')}>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
{(['toggle', 'hold'] as const).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() =>
|
||||
updatePrefs({ ...prefs, remoteInputDefaultMode: m })
|
||||
}
|
||||
style={{
|
||||
padding: '5px 12px',
|
||||
borderRadius: 8,
|
||||
fontSize: 12.5,
|
||||
cursor: 'pointer',
|
||||
border: '0.5px solid var(--ol-line-strong)',
|
||||
background:
|
||||
mode === m ? 'var(--ol-blue)' : 'var(--ol-surface-2)',
|
||||
color: mode === m ? '#fff' : 'var(--ol-ink)',
|
||||
}}
|
||||
>
|
||||
{t(
|
||||
m === 'toggle'
|
||||
? 'settings.remoteInput.modeToggle'
|
||||
: 'settings.remoteInput.modeHold',
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</SettingRow>
|
||||
|
||||
{enabled && status?.running && (
|
||||
<>
|
||||
<SettingRow label={t('settings.remoteInput.urlLabel')}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 6,
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
{(status.urls.length
|
||||
? status.urls
|
||||
: [`https://localhost:${status.port}`]
|
||||
).map((u) => (
|
||||
<div
|
||||
key={u}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 8 }}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12.5,
|
||||
color: 'var(--ol-ink-2)',
|
||||
wordBreak: 'break-all',
|
||||
}}
|
||||
>
|
||||
{u}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => doCopy(u, status.pin)}
|
||||
title={t('settings.remoteInput.urlLabel')}
|
||||
style={smallBtn}
|
||||
>
|
||||
{copied === u ? '✓' : '⧉'}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow label={t('settings.remoteInput.pinLabel')}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<code
|
||||
style={{ fontSize: 15, letterSpacing: 2, fontWeight: 600 }}
|
||||
>
|
||||
{status.pin}
|
||||
</code>
|
||||
<button
|
||||
onClick={async () => {
|
||||
await regenerateRemotePin();
|
||||
const s = await getRemoteInputStatus();
|
||||
setStatus(s);
|
||||
}}
|
||||
style={smallBtn}
|
||||
>
|
||||
{t('settings.remoteInput.regeneratePin')}
|
||||
</button>
|
||||
</div>
|
||||
</SettingRow>
|
||||
</>
|
||||
)}
|
||||
|
||||
{enabled && errorPort != null && (
|
||||
<div style={{ fontSize: 12, color: '#d9534f', marginTop: 8 }}>
|
||||
{t('settings.remoteInput.portInUse', { port: errorPort })}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
fontSize: 11.5,
|
||||
color: 'var(--ol-ink-4)',
|
||||
marginTop: 10,
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
>
|
||||
{t('settings.remoteInput.securityHint')}
|
||||
<br />
|
||||
{t('settings.remoteInput.certHint')}
|
||||
</div>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<>
|
||||
<RecordingInputSection />
|
||||
<RemoteInputSection />
|
||||
<ShortcutsSection />
|
||||
<LanguageSection />
|
||||
</>
|
||||
|
||||
Reference in new issue
Block a user