feat: 手机远程录入

This commit is contained in:
ciddwd committed 2026-06-06 09:07:40 +08:00
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
+139
View File
@@ -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_
+99
View File
@@ -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]
+64
View File
@@ -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
+181
View File
@@ -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"
+8
View File
@@ -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 最少间隔),
+6
View File
@@ -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
}
+41
View File
@@ -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(),
}
}
}
+15
View File
@@ -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',
+15
View File
@@ -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: 'アップデート確認',
+15
View File
@@ -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: '업데이트 확인',
+15
View File
@@ -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: '检查更新',
+15
View File
@@ -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: '檢查更新',
+29
View File
@@ -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 = {
+8
View File
@@ -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 />
</>