From c653f90056b17df77144dbfec0436d50e047afc7 Mon Sep 17 00:00:00 2001 From: xuvian Date: Sat, 11 Apr 2026 21:57:36 +0800 Subject: [PATCH 01/15] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=82=AE=E7=AE=B1?= =?UTF-8?q?=E5=88=87=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .spec-workflow/templates/design-template.md | 96 ++++++++++++ .spec-workflow/templates/product-template.md | 51 ++++++ .../templates/requirements-template.md | 50 ++++++ .../templates/structure-template.md | 145 ++++++++++++++++++ .spec-workflow/templates/tasks-template.md | 139 +++++++++++++++++ .spec-workflow/templates/tech-template.md | 99 ++++++++++++ .spec-workflow/user-templates/README.md | 64 ++++++++ background.js | 123 ++++++++++----- content/inbucket-mail.js | 13 +- 9 files changed, 736 insertions(+), 44 deletions(-) create mode 100644 .spec-workflow/templates/design-template.md create mode 100644 .spec-workflow/templates/product-template.md create mode 100644 .spec-workflow/templates/requirements-template.md create mode 100644 .spec-workflow/templates/structure-template.md create mode 100644 .spec-workflow/templates/tasks-template.md create mode 100644 .spec-workflow/templates/tech-template.md create mode 100644 .spec-workflow/user-templates/README.md diff --git a/.spec-workflow/templates/design-template.md b/.spec-workflow/templates/design-template.md new file mode 100644 index 00000000..1295d7b4 --- /dev/null +++ b/.spec-workflow/templates/design-template.md @@ -0,0 +1,96 @@ +# Design Document + +## Overview + +[High-level description of the feature and its place in the overall system] + +## Steering Document Alignment + +### Technical Standards (tech.md) +[How the design follows documented technical patterns and standards] + +### Project Structure (structure.md) +[How the implementation will follow project organization conventions] + +## Code Reuse Analysis +[What existing code will be leveraged, extended, or integrated with this feature] + +### Existing Components to Leverage +- **[Component/Utility Name]**: [How it will be used] +- **[Service/Helper Name]**: [How it will be extended] + +### Integration Points +- **[Existing System/API]**: [How the new feature will integrate] +- **[Database/Storage]**: [How data will connect to existing schemas] + +## Architecture + +[Describe the overall architecture and design patterns used] + +### Modular Design Principles +- **Single File Responsibility**: Each file should handle one specific concern or domain +- **Component Isolation**: Create small, focused components rather than large monolithic files +- **Service Layer Separation**: Separate data access, business logic, and presentation layers +- **Utility Modularity**: Break utilities into focused, single-purpose modules + +```mermaid +graph TD + A[Component A] --> B[Component B] + B --> C[Component C] +``` + +## Components and Interfaces + +### Component 1 +- **Purpose:** [What this component does] +- **Interfaces:** [Public methods/APIs] +- **Dependencies:** [What it depends on] +- **Reuses:** [Existing components/utilities it builds upon] + +### Component 2 +- **Purpose:** [What this component does] +- **Interfaces:** [Public methods/APIs] +- **Dependencies:** [What it depends on] +- **Reuses:** [Existing components/utilities it builds upon] + +## Data Models + +### Model 1 +``` +[Define the structure of Model1 in your language] +- id: [unique identifier type] +- name: [string/text type] +- [Additional properties as needed] +``` + +### Model 2 +``` +[Define the structure of Model2 in your language] +- id: [unique identifier type] +- [Additional properties as needed] +``` + +## Error Handling + +### Error Scenarios +1. **Scenario 1:** [Description] + - **Handling:** [How to handle] + - **User Impact:** [What user sees] + +2. **Scenario 2:** [Description] + - **Handling:** [How to handle] + - **User Impact:** [What user sees] + +## Testing Strategy + +### Unit Testing +- [Unit testing approach] +- [Key components to test] + +### Integration Testing +- [Integration testing approach] +- [Key flows to test] + +### End-to-End Testing +- [E2E testing approach] +- [User scenarios to test] diff --git a/.spec-workflow/templates/product-template.md b/.spec-workflow/templates/product-template.md new file mode 100644 index 00000000..82e60de2 --- /dev/null +++ b/.spec-workflow/templates/product-template.md @@ -0,0 +1,51 @@ +# Product Overview + +## Product Purpose +[Describe the core purpose of this product/project. What problem does it solve?] + +## Target Users +[Who are the primary users of this product? What are their needs and pain points?] + +## Key Features +[List the main features that deliver value to users] + +1. **Feature 1**: [Description] +2. **Feature 2**: [Description] +3. **Feature 3**: [Description] + +## Business Objectives +[What are the business goals this product aims to achieve?] + +- [Objective 1] +- [Objective 2] +- [Objective 3] + +## Success Metrics +[How will we measure the success of this product?] + +- [Metric 1]: [Target] +- [Metric 2]: [Target] +- [Metric 3]: [Target] + +## Product Principles +[Core principles that guide product decisions] + +1. **[Principle 1]**: [Explanation] +2. **[Principle 2]**: [Explanation] +3. **[Principle 3]**: [Explanation] + +## Monitoring & Visibility (if applicable) +[How do users track progress and monitor the system?] + +- **Dashboard Type**: [e.g., Web-based, CLI, Desktop app] +- **Real-time Updates**: [e.g., WebSocket, polling, push notifications] +- **Key Metrics Displayed**: [What information is most important to surface] +- **Sharing Capabilities**: [e.g., read-only links, exports, reports] + +## Future Vision +[Where do we see this product evolving in the future?] + +### Potential Enhancements +- **Remote Access**: [e.g., Tunnel features for sharing dashboards with stakeholders] +- **Analytics**: [e.g., Historical trends, performance metrics] +- **Collaboration**: [e.g., Multi-user support, commenting] diff --git a/.spec-workflow/templates/requirements-template.md b/.spec-workflow/templates/requirements-template.md new file mode 100644 index 00000000..1c80ca0d --- /dev/null +++ b/.spec-workflow/templates/requirements-template.md @@ -0,0 +1,50 @@ +# Requirements Document + +## Introduction + +[Provide a brief overview of the feature, its purpose, and its value to users] + +## Alignment with Product Vision + +[Explain how this feature supports the goals outlined in product.md] + +## Requirements + +### Requirement 1 + +**User Story:** As a [role], I want [feature], so that [benefit] + +#### Acceptance Criteria + +1. WHEN [event] THEN [system] SHALL [response] +2. IF [precondition] THEN [system] SHALL [response] +3. WHEN [event] AND [condition] THEN [system] SHALL [response] + +### Requirement 2 + +**User Story:** As a [role], I want [feature], so that [benefit] + +#### Acceptance Criteria + +1. WHEN [event] THEN [system] SHALL [response] +2. IF [precondition] THEN [system] SHALL [response] + +## Non-Functional Requirements + +### Code Architecture and Modularity +- **Single Responsibility Principle**: Each file should have a single, well-defined purpose +- **Modular Design**: Components, utilities, and services should be isolated and reusable +- **Dependency Management**: Minimize interdependencies between modules +- **Clear Interfaces**: Define clean contracts between components and layers + +### Performance +- [Performance requirements] + +### Security +- [Security requirements] + +### Reliability +- [Reliability requirements] + +### Usability +- [Usability requirements] diff --git a/.spec-workflow/templates/structure-template.md b/.spec-workflow/templates/structure-template.md new file mode 100644 index 00000000..1ab1fbcc --- /dev/null +++ b/.spec-workflow/templates/structure-template.md @@ -0,0 +1,145 @@ +# Project Structure + +## Directory Organization + +``` +[Define your project's directory structure. Examples below - adapt to your project type] + +Example for a library/package: +project-root/ +├── src/ # Source code +├── tests/ # Test files +├── docs/ # Documentation +├── examples/ # Usage examples +└── [build/dist/out] # Build output + +Example for an application: +project-root/ +├── [src/app/lib] # Main source code +├── [assets/resources] # Static resources +├── [config/settings] # Configuration +├── [scripts/tools] # Build/utility scripts +└── [tests/spec] # Test files + +Common patterns: +- Group by feature/module +- Group by layer (UI, business logic, data) +- Group by type (models, controllers, views) +- Flat structure for simple projects +``` + +## Naming Conventions + +### Files +- **Components/Modules**: [e.g., `PascalCase`, `snake_case`, `kebab-case`] +- **Services/Handlers**: [e.g., `UserService`, `user_service`, `user-service`] +- **Utilities/Helpers**: [e.g., `dateUtils`, `date_utils`, `date-utils`] +- **Tests**: [e.g., `[filename]_test`, `[filename].test`, `[filename]Test`] + +### Code +- **Classes/Types**: [e.g., `PascalCase`, `CamelCase`, `snake_case`] +- **Functions/Methods**: [e.g., `camelCase`, `snake_case`, `PascalCase`] +- **Constants**: [e.g., `UPPER_SNAKE_CASE`, `SCREAMING_CASE`, `PascalCase`] +- **Variables**: [e.g., `camelCase`, `snake_case`, `lowercase`] + +## Import Patterns + +### Import Order +1. External dependencies +2. Internal modules +3. Relative imports +4. Style imports + +### Module/Package Organization +``` +[Describe your project's import/include patterns] +Examples: +- Absolute imports from project root +- Relative imports within modules +- Package/namespace organization +- Dependency management approach +``` + +## Code Structure Patterns + +[Define common patterns for organizing code within files. Below are examples - choose what applies to your project] + +### Module/Class Organization +``` +Example patterns: +1. Imports/includes/dependencies +2. Constants and configuration +3. Type/interface definitions +4. Main implementation +5. Helper/utility functions +6. Exports/public API +``` + +### Function/Method Organization +``` +Example patterns: +- Input validation first +- Core logic in the middle +- Error handling throughout +- Clear return points +``` + +### File Organization Principles +``` +Choose what works for your project: +- One class/module per file +- Related functionality grouped together +- Public API at the top/bottom +- Implementation details hidden +``` + +## Code Organization Principles + +1. **Single Responsibility**: Each file should have one clear purpose +2. **Modularity**: Code should be organized into reusable modules +3. **Testability**: Structure code to be easily testable +4. **Consistency**: Follow patterns established in the codebase + +## Module Boundaries +[Define how different parts of your project interact and maintain separation of concerns] + +Examples of boundary patterns: +- **Core vs Plugins**: Core functionality vs extensible plugins +- **Public API vs Internal**: What's exposed vs implementation details +- **Platform-specific vs Cross-platform**: OS-specific code isolation +- **Stable vs Experimental**: Production code vs experimental features +- **Dependencies direction**: Which modules can depend on which + +## Code Size Guidelines +[Define your project's guidelines for file and function sizes] + +Suggested guidelines: +- **File size**: [Define maximum lines per file] +- **Function/Method size**: [Define maximum lines per function] +- **Class/Module complexity**: [Define complexity limits] +- **Nesting depth**: [Maximum nesting levels] + +## Dashboard/Monitoring Structure (if applicable) +[How dashboard or monitoring components are organized] + +### Example Structure: +``` +src/ +└── dashboard/ # Self-contained dashboard subsystem + ├── server/ # Backend server components + ├── client/ # Frontend assets + ├── shared/ # Shared types/utilities + └── public/ # Static assets +``` + +### Separation of Concerns +- Dashboard isolated from core business logic +- Own CLI entry point for independent operation +- Minimal dependencies on main application +- Can be disabled without affecting core functionality + +## Documentation Standards +- All public APIs must have documentation +- Complex logic should include inline comments +- README files for major modules +- Follow language-specific documentation conventions diff --git a/.spec-workflow/templates/tasks-template.md b/.spec-workflow/templates/tasks-template.md new file mode 100644 index 00000000..be461de5 --- /dev/null +++ b/.spec-workflow/templates/tasks-template.md @@ -0,0 +1,139 @@ +# Tasks Document + +- [ ] 1. Create core interfaces in src/types/feature.ts + - File: src/types/feature.ts + - Define TypeScript interfaces for feature data structures + - Extend existing base interfaces from base.ts + - Purpose: Establish type safety for feature implementation + - _Leverage: src/types/base.ts_ + - _Requirements: 1.1_ + - _Prompt: Role: TypeScript Developer specializing in type systems and interfaces | Task: Create comprehensive TypeScript interfaces for the feature data structures following requirements 1.1, extending existing base interfaces from src/types/base.ts | Restrictions: Do not modify existing base interfaces, maintain backward compatibility, follow project naming conventions | Success: All interfaces compile without errors, proper inheritance from base types, full type coverage for feature requirements_ + +- [ ] 2. Create base model class in src/models/FeatureModel.ts + - File: src/models/FeatureModel.ts + - Implement base model extending BaseModel class + - Add validation methods using existing validation utilities + - Purpose: Provide data layer foundation for feature + - _Leverage: src/models/BaseModel.ts, src/utils/validation.ts_ + - _Requirements: 2.1_ + - _Prompt: Role: Backend Developer with expertise in Node.js and data modeling | Task: Create a base model class extending BaseModel and implementing validation following requirement 2.1, leveraging existing patterns from src/models/BaseModel.ts and src/utils/validation.ts | Restrictions: Must follow existing model patterns, do not bypass validation utilities, maintain consistent error handling | Success: Model extends BaseModel correctly, validation methods implemented and tested, follows project architecture patterns_ + +- [ ] 3. Add specific model methods to FeatureModel.ts + - File: src/models/FeatureModel.ts (continue from task 2) + - Implement create, update, delete methods + - Add relationship handling for foreign keys + - Purpose: Complete model functionality for CRUD operations + - _Leverage: src/models/BaseModel.ts_ + - _Requirements: 2.2, 2.3_ + - _Prompt: Role: Backend Developer with expertise in ORM and database operations | Task: Implement CRUD methods and relationship handling in FeatureModel.ts following requirements 2.2 and 2.3, extending patterns from src/models/BaseModel.ts | Restrictions: Must maintain transaction integrity, follow existing relationship patterns, do not duplicate base model functionality | Success: All CRUD operations work correctly, relationships are properly handled, database operations are atomic and efficient_ + +- [ ] 4. Create model unit tests in tests/models/FeatureModel.test.ts + - File: tests/models/FeatureModel.test.ts + - Write tests for model validation and CRUD methods + - Use existing test utilities and fixtures + - Purpose: Ensure model reliability and catch regressions + - _Leverage: tests/helpers/testUtils.ts, tests/fixtures/data.ts_ + - _Requirements: 2.1, 2.2_ + - _Prompt: Role: QA Engineer with expertise in unit testing and Jest/Mocha frameworks | Task: Create comprehensive unit tests for FeatureModel validation and CRUD methods covering requirements 2.1 and 2.2, using existing test utilities from tests/helpers/testUtils.ts and fixtures from tests/fixtures/data.ts | Restrictions: Must test both success and failure scenarios, do not test external dependencies directly, maintain test isolation | Success: All model methods are tested with good coverage, edge cases covered, tests run independently and consistently_ + +- [ ] 5. Create service interface in src/services/IFeatureService.ts + - File: src/services/IFeatureService.ts + - Define service contract with method signatures + - Extend base service interface patterns + - Purpose: Establish service layer contract for dependency injection + - _Leverage: src/services/IBaseService.ts_ + - _Requirements: 3.1_ + - _Prompt: Role: Software Architect specializing in service-oriented architecture and TypeScript interfaces | Task: Design service interface contract following requirement 3.1, extending base service patterns from src/services/IBaseService.ts for dependency injection | Restrictions: Must maintain interface segregation principle, do not expose internal implementation details, ensure contract compatibility with DI container | Success: Interface is well-defined with clear method signatures, extends base service appropriately, supports all required service operations_ + +- [ ] 6. Implement feature service in src/services/FeatureService.ts + - File: src/services/FeatureService.ts + - Create concrete service implementation using FeatureModel + - Add error handling with existing error utilities + - Purpose: Provide business logic layer for feature operations + - _Leverage: src/services/BaseService.ts, src/utils/errorHandler.ts, src/models/FeatureModel.ts_ + - _Requirements: 3.2_ + - _Prompt: Role: Backend Developer with expertise in service layer architecture and business logic | Task: Implement concrete FeatureService following requirement 3.2, using FeatureModel and extending BaseService patterns with proper error handling from src/utils/errorHandler.ts | Restrictions: Must implement interface contract exactly, do not bypass model validation, maintain separation of concerns from data layer | Success: Service implements all interface methods correctly, robust error handling implemented, business logic is well-encapsulated and testable_ + +- [ ] 7. Add service dependency injection in src/utils/di.ts + - File: src/utils/di.ts (modify existing) + - Register FeatureService in dependency injection container + - Configure service lifetime and dependencies + - Purpose: Enable service injection throughout application + - _Leverage: existing DI configuration in src/utils/di.ts_ + - _Requirements: 3.1_ + - _Prompt: Role: DevOps Engineer with expertise in dependency injection and IoC containers | Task: Register FeatureService in DI container following requirement 3.1, configuring appropriate lifetime and dependencies using existing patterns from src/utils/di.ts | Restrictions: Must follow existing DI container patterns, do not create circular dependencies, maintain service resolution efficiency | Success: FeatureService is properly registered and resolvable, dependencies are correctly configured, service lifetime is appropriate for use case_ + +- [ ] 8. Create service unit tests in tests/services/FeatureService.test.ts + - File: tests/services/FeatureService.test.ts + - Write tests for service methods with mocked dependencies + - Test error handling scenarios + - Purpose: Ensure service reliability and proper error handling + - _Leverage: tests/helpers/testUtils.ts, tests/mocks/modelMocks.ts_ + - _Requirements: 3.2, 3.3_ + - _Prompt: Role: QA Engineer with expertise in service testing and mocking frameworks | Task: Create comprehensive unit tests for FeatureService methods covering requirements 3.2 and 3.3, using mocked dependencies from tests/mocks/modelMocks.ts and test utilities | Restrictions: Must mock all external dependencies, test business logic in isolation, do not test framework code | Success: All service methods tested with proper mocking, error scenarios covered, tests verify business logic correctness and error handling_ + +- [ ] 4. Create API endpoints + - Design API structure + - _Leverage: src/api/baseApi.ts, src/utils/apiUtils.ts_ + - _Requirements: 4.0_ + - _Prompt: Role: API Architect specializing in RESTful design and Express.js | Task: Design comprehensive API structure following requirement 4.0, leveraging existing patterns from src/api/baseApi.ts and utilities from src/utils/apiUtils.ts | Restrictions: Must follow REST conventions, maintain API versioning compatibility, do not expose internal data structures directly | Success: API structure is well-designed and documented, follows existing patterns, supports all required operations with proper HTTP methods and status codes_ + +- [ ] 4.1 Set up routing and middleware + - Configure application routes + - Add authentication middleware + - Set up error handling middleware + - _Leverage: src/middleware/auth.ts, src/middleware/errorHandler.ts_ + - _Requirements: 4.1_ + - _Prompt: Role: Backend Developer with expertise in Express.js middleware and routing | Task: Configure application routes and middleware following requirement 4.1, integrating authentication from src/middleware/auth.ts and error handling from src/middleware/errorHandler.ts | Restrictions: Must maintain middleware order, do not bypass security middleware, ensure proper error propagation | Success: Routes are properly configured with correct middleware chain, authentication works correctly, errors are handled gracefully throughout the request lifecycle_ + +- [ ] 4.2 Implement CRUD endpoints + - Create API endpoints + - Add request validation + - Write API integration tests + - _Leverage: src/controllers/BaseController.ts, src/utils/validation.ts_ + - _Requirements: 4.2, 4.3_ + - _Prompt: Role: Full-stack Developer with expertise in API development and validation | Task: Implement CRUD endpoints following requirements 4.2 and 4.3, extending BaseController patterns and using validation utilities from src/utils/validation.ts | Restrictions: Must validate all inputs, follow existing controller patterns, ensure proper HTTP status codes and responses | Success: All CRUD operations work correctly, request validation prevents invalid data, integration tests pass and cover all endpoints_ + +- [ ] 5. Add frontend components + - Plan component architecture + - _Leverage: src/components/BaseComponent.tsx, src/styles/theme.ts_ + - _Requirements: 5.0_ + - _Prompt: Role: Frontend Architect with expertise in React component design and architecture | Task: Plan comprehensive component architecture following requirement 5.0, leveraging base patterns from src/components/BaseComponent.tsx and theme system from src/styles/theme.ts | Restrictions: Must follow existing component patterns, maintain design system consistency, ensure component reusability | Success: Architecture is well-planned and documented, components are properly organized, follows existing patterns and theme system_ + +- [ ] 5.1 Create base UI components + - Set up component structure + - Implement reusable components + - Add styling and theming + - _Leverage: src/components/BaseComponent.tsx, src/styles/theme.ts_ + - _Requirements: 5.1_ + - _Prompt: Role: Frontend Developer specializing in React and component architecture | Task: Create reusable UI components following requirement 5.1, extending BaseComponent patterns and using existing theme system from src/styles/theme.ts | Restrictions: Must use existing theme variables, follow component composition patterns, ensure accessibility compliance | Success: Components are reusable and properly themed, follow existing architecture, accessible and responsive_ + +- [ ] 5.2 Implement feature-specific components + - Create feature components + - Add state management + - Connect to API endpoints + - _Leverage: src/hooks/useApi.ts, src/components/BaseComponent.tsx_ + - _Requirements: 5.2, 5.3_ + - _Prompt: Role: React Developer with expertise in state management and API integration | Task: Implement feature-specific components following requirements 5.2 and 5.3, using API hooks from src/hooks/useApi.ts and extending BaseComponent patterns | Restrictions: Must use existing state management patterns, handle loading and error states properly, maintain component performance | Success: Components are fully functional with proper state management, API integration works smoothly, user experience is responsive and intuitive_ + +- [ ] 6. Integration and testing + - Plan integration approach + - _Leverage: src/utils/integrationUtils.ts, tests/helpers/testUtils.ts_ + - _Requirements: 6.0_ + - _Prompt: Role: Integration Engineer with expertise in system integration and testing strategies | Task: Plan comprehensive integration approach following requirement 6.0, leveraging integration utilities from src/utils/integrationUtils.ts and test helpers | Restrictions: Must consider all system components, ensure proper test coverage, maintain integration test reliability | Success: Integration plan is comprehensive and feasible, all system components work together correctly, integration points are well-tested_ + +- [ ] 6.1 Write end-to-end tests + - Set up E2E testing framework + - Write user journey tests + - Add test automation + - _Leverage: tests/helpers/testUtils.ts, tests/fixtures/data.ts_ + - _Requirements: All_ + - _Prompt: Role: QA Automation Engineer with expertise in E2E testing and test frameworks like Cypress or Playwright | Task: Implement comprehensive end-to-end tests covering all requirements, setting up testing framework and user journey tests using test utilities and fixtures | Restrictions: Must test real user workflows, ensure tests are maintainable and reliable, do not test implementation details | Success: E2E tests cover all critical user journeys, tests run reliably in CI/CD pipeline, user experience is validated from end-to-end_ + +- [ ] 6.2 Final integration and cleanup + - Integrate all components + - Fix any integration issues + - Clean up code and documentation + - _Leverage: src/utils/cleanup.ts, docs/templates/_ + - _Requirements: All_ + - _Prompt: Role: Senior Developer with expertise in code quality and system integration | Task: Complete final integration of all components and perform comprehensive cleanup covering all requirements, using cleanup utilities and documentation templates | Restrictions: Must not break existing functionality, ensure code quality standards are met, maintain documentation consistency | Success: All components are fully integrated and working together, code is clean and well-documented, system meets all requirements and quality standards_ diff --git a/.spec-workflow/templates/tech-template.md b/.spec-workflow/templates/tech-template.md new file mode 100644 index 00000000..57cd538d --- /dev/null +++ b/.spec-workflow/templates/tech-template.md @@ -0,0 +1,99 @@ +# Technology Stack + +## Project Type +[Describe what kind of project this is: web application, CLI tool, desktop application, mobile app, library, API service, embedded system, game, etc.] + +## Core Technologies + +### Primary Language(s) +- **Language**: [e.g., Python 3.11, Go 1.21, TypeScript, Rust, C++] +- **Runtime/Compiler**: [if applicable] +- **Language-specific tools**: [package managers, build tools, etc.] + +### Key Dependencies/Libraries +[List the main libraries and frameworks your project depends on] +- **[Library/Framework name]**: [Purpose and version] +- **[Library/Framework name]**: [Purpose and version] + +### Application Architecture +[Describe how your application is structured - this could be MVC, event-driven, plugin-based, client-server, standalone, microservices, monolithic, etc.] + +### Data Storage (if applicable) +- **Primary storage**: [e.g., PostgreSQL, files, in-memory, cloud storage] +- **Caching**: [e.g., Redis, in-memory, disk cache] +- **Data formats**: [e.g., JSON, Protocol Buffers, XML, binary] + +### External Integrations (if applicable) +- **APIs**: [External services you integrate with] +- **Protocols**: [e.g., HTTP/REST, gRPC, WebSocket, TCP/IP] +- **Authentication**: [e.g., OAuth, API keys, certificates] + +### Monitoring & Dashboard Technologies (if applicable) +- **Dashboard Framework**: [e.g., React, Vue, vanilla JS, terminal UI] +- **Real-time Communication**: [e.g., WebSocket, Server-Sent Events, polling] +- **Visualization Libraries**: [e.g., Chart.js, D3, terminal graphs] +- **State Management**: [e.g., Redux, Vuex, file system as source of truth] + +## Development Environment + +### Build & Development Tools +- **Build System**: [e.g., Make, CMake, Gradle, npm scripts, cargo] +- **Package Management**: [e.g., pip, npm, cargo, go mod, apt, brew] +- **Development workflow**: [e.g., hot reload, watch mode, REPL] + +### Code Quality Tools +- **Static Analysis**: [Tools for code quality and correctness] +- **Formatting**: [Code style enforcement tools] +- **Testing Framework**: [Unit, integration, and/or end-to-end testing tools] +- **Documentation**: [Documentation generation tools] + +### Version Control & Collaboration +- **VCS**: [e.g., Git, Mercurial, SVN] +- **Branching Strategy**: [e.g., Git Flow, GitHub Flow, trunk-based] +- **Code Review Process**: [How code reviews are conducted] + +### Dashboard Development (if applicable) +- **Live Reload**: [e.g., Hot module replacement, file watchers] +- **Port Management**: [e.g., Dynamic allocation, configurable ports] +- **Multi-Instance Support**: [e.g., Running multiple dashboards simultaneously] + +## Deployment & Distribution (if applicable) +- **Target Platform(s)**: [Where/how the project runs: cloud, on-premise, desktop, mobile, embedded] +- **Distribution Method**: [How users get your software: download, package manager, app store, SaaS] +- **Installation Requirements**: [Prerequisites, system requirements] +- **Update Mechanism**: [How updates are delivered] + +## Technical Requirements & Constraints + +### Performance Requirements +- [e.g., response time, throughput, memory usage, startup time] +- [Specific benchmarks or targets] + +### Compatibility Requirements +- **Platform Support**: [Operating systems, architectures, versions] +- **Dependency Versions**: [Minimum/maximum versions of dependencies] +- **Standards Compliance**: [Industry standards, protocols, specifications] + +### Security & Compliance +- **Security Requirements**: [Authentication, encryption, data protection] +- **Compliance Standards**: [GDPR, HIPAA, SOC2, etc. if applicable] +- **Threat Model**: [Key security considerations] + +### Scalability & Reliability +- **Expected Load**: [Users, requests, data volume] +- **Availability Requirements**: [Uptime targets, disaster recovery] +- **Growth Projections**: [How the system needs to scale] + +## Technical Decisions & Rationale +[Document key architectural and technology choices] + +### Decision Log +1. **[Technology/Pattern Choice]**: [Why this was chosen, alternatives considered] +2. **[Architecture Decision]**: [Rationale, trade-offs accepted] +3. **[Tool/Library Selection]**: [Reasoning, evaluation criteria] + +## Known Limitations +[Document any technical debt, limitations, or areas for improvement] + +- [Limitation 1]: [Impact and potential future solutions] +- [Limitation 2]: [Why it exists and when it might be addressed] diff --git a/.spec-workflow/user-templates/README.md b/.spec-workflow/user-templates/README.md new file mode 100644 index 00000000..ad36a48b --- /dev/null +++ b/.spec-workflow/user-templates/README.md @@ -0,0 +1,64 @@ +# User Templates + +This directory allows you to create custom templates that override the default Spec Workflow templates. + +## How to Use Custom Templates + +1. **Create your custom template file** in this directory with the exact same name as the default template you want to override: + - `requirements-template.md` - Override requirements document template + - `design-template.md` - Override design document template + - `tasks-template.md` - Override tasks document template + - `product-template.md` - Override product steering template + - `tech-template.md` - Override tech steering template + - `structure-template.md` - Override structure steering template + +2. **Template Loading Priority**: + - The system first checks this `user-templates/` directory + - If a matching template is found here, it will be used + - Otherwise, the default template from `templates/` will be used + +## Example Custom Template + +To create a custom requirements template: + +1. Create a file named `requirements-template.md` in this directory +2. Add your custom structure, for example: + +```markdown +# Requirements Document + +## Executive Summary +[Your custom section] + +## Business Requirements +[Your custom structure] + +## Technical Requirements +[Your custom fields] + +## Custom Sections +[Add any sections specific to your workflow] +``` + +## Template Variables + +Templates can include placeholders that will be replaced when documents are created: +- `{{projectName}}` - The name of your project +- `{{featureName}}` - The name of the feature being specified +- `{{date}}` - The current date +- `{{author}}` - The document author + +## Best Practices + +1. **Start from defaults**: Copy a default template from `../templates/` as a starting point +2. **Keep structure consistent**: Maintain similar section headers for tool compatibility +3. **Document changes**: Add comments explaining why sections were added/modified +4. **Version control**: Track your custom templates in version control +5. **Test thoroughly**: Ensure custom templates work with the spec workflow tools + +## Notes + +- Custom templates are project-specific and not included in the package distribution +- The `templates/` directory contains the default templates which are updated with each version +- Your custom templates in this directory are preserved during updates +- If a custom template has errors, the system will fall back to the default template diff --git a/background.js b/background.js index 125b3a7e..29a8d3c4 100644 --- a/background.js +++ b/background.js @@ -210,6 +210,37 @@ async function getTabId(source) { return registry[source]?.tabId || null; } +async function activateTabWithRetry(tabId, options = {}) { + const { + retries = 3, + delayMs = 400, + logLabel = '', + } = options; + + let lastError = null; + + for (let attempt = 1; attempt <= retries; attempt++) { + try { + await chrome.tabs.update(tabId, { active: true }); + return; + } catch (err) { + lastError = err; + const message = err?.message || String(err); + const isTabBusyError = /Tabs cannot be edited right now/i.test(message); + if (!isTabBusyError || attempt >= retries) { + throw err; + } + + if (logLabel) { + await addLog(`${logLabel}:标签页暂时不可切换,正在重试(${attempt + 1}/${retries})...`, 'warn'); + } + await sleepWithStop(delayMs); + } + } + + throw lastError; +} + function parseUrlSafely(rawUrl) { if (!rawUrl) return null; try { @@ -610,6 +641,39 @@ async function reuseOrCreateTab(source, url, options = {}) { return tab.id; } +async function focusMailTabForVerification(mail, step) { + const logLabel = `步骤 ${step}`; + let tabId = null; + + const alive = await isTabAlive(mail.source); + if (alive) { + if (mail.navigateOnReuse) { + tabId = await reuseOrCreateTab(mail.source, mail.url, { + inject: mail.inject, + injectSource: mail.injectSource, + }); + } else { + tabId = await getTabId(mail.source); + await activateTabWithRetry(tabId, { logLabel }); + } + } else { + tabId = await reuseOrCreateTab(mail.source, mail.url, { + inject: mail.inject, + injectSource: mail.injectSource, + }); + } + + if (Number.isInteger(tabId) && mail.inject?.length) { + await ensureContentScriptReadyOnTab(mail.source, tabId, { + inject: mail.inject, + injectSource: mail.injectSource, + timeoutMs: 20000, + retryDelayMs: 700, + logMessage: `${logLabel}:正在等待${mail.label}内容脚本重新就绪...`, + }); + } +} + // ============================================================ // Send command to content script (with readiness check) // ============================================================ @@ -2014,7 +2078,7 @@ async function requestVerificationCodeResend(step) { throw new Error('认证页面标签页已关闭,无法重新请求验证码。'); } - await chrome.tabs.update(signupTabId, { active: true }); + await activateTabWithRetry(signupTabId, { logLabel: `步骤 ${step}` }); await addLog(`步骤 ${step}:正在请求新的${getVerificationCodeLabel(step)}验证码...`, 'warn'); const result = await sendToContentScript('signup-page', { @@ -2048,6 +2112,7 @@ async function pollFreshVerificationCode(step, state, mail, pollOverrides = {}) for (let round = 1; round <= maxRounds; round++) { if (round > 1) { await requestVerificationCodeResend(step); + await focusMailTabForVerification(mail, step); } const payload = getVerificationPollPayload(step, state, { @@ -2102,7 +2167,7 @@ async function submitVerificationCode(step, code) { throw new Error('认证页面标签页已关闭,无法填写验证码。'); } - await chrome.tabs.update(signupTabId, { active: true }); + await activateTabWithRetry(signupTabId, { logLabel: `步骤 ${step}` }); const result = await sendToContentScript('signup-page', { type: 'FILL_CODE', step, @@ -2126,11 +2191,16 @@ async function resolveVerificationStep(step, state, mail, options = {}) { const nextFilterAfterTimestamp = options.filterAfterTimestamp ?? null; const requestFreshCodeFirst = Boolean(options.requestFreshCodeFirst); + const initialMailboxRefreshFirst = Boolean(options.initialMailboxRefreshFirst); + const initialMailboxRefreshDelayMs = Number(options.initialMailboxRefreshDelayMs) > 0 + ? Number(options.initialMailboxRefreshDelayMs) + : 2000; const maxSubmitAttempts = 3; if (requestFreshCodeFirst) { try { await requestVerificationCodeResend(step); + await focusMailTabForVerification(mail, step); await addLog(`步骤 ${step}:已先请求一封新的${getVerificationCodeLabel(step)}验证码,再开始轮询邮箱。`, 'warn'); } catch (err) { await addLog(`步骤 ${step}:首次重新获取验证码失败:${err.message},将继续使用当前时间窗口轮询。`, 'warn'); @@ -2141,6 +2211,8 @@ async function resolveVerificationStep(step, state, mail, options = {}) { const result = await pollFreshVerificationCode(step, state, mail, { excludeCodes: [...rejectedCodes], filterAfterTimestamp: nextFilterAfterTimestamp ?? undefined, + initialRefreshFirst: initialMailboxRefreshFirst && attempt === 1, + initialRefreshDelayMs: initialMailboxRefreshDelayMs, }); await addLog(`步骤 ${step}:已获取${getVerificationCodeLabel(step)}验证码:${result.code}`); @@ -2155,6 +2227,7 @@ async function resolveVerificationStep(step, state, mail, options = {}) { } await requestVerificationCodeResend(step); + await focusMailTabForVerification(mail, step); await addLog(`步骤 ${step}:提交失败后已请求新验证码(${attempt + 1}/${maxSubmitAttempts})...`, 'warn'); continue; } @@ -2181,7 +2254,7 @@ async function executeStep4(state) { throw new Error('认证页面标签页已关闭,无法继续步骤 4。'); } - await chrome.tabs.update(signupTabId, { active: true }); + await activateTabWithRetry(signupTabId, { logLabel: '步骤 4' }); await addLog('步骤 4:正在确认注册验证码页面是否就绪,必要时自动恢复密码页超时报错...'); const prepareResult = await sendToContentScriptResilient( 'signup-page', @@ -2207,29 +2280,12 @@ async function executeStep4(state) { } await addLog(`步骤 4:正在打开${mail.label}...`); - - // For mail tabs, only create if not alive — don't navigate (preserves login session) - const alive = await isTabAlive(mail.source); - if (alive) { - if (mail.navigateOnReuse) { - await reuseOrCreateTab(mail.source, mail.url, { - inject: mail.inject, - injectSource: mail.injectSource, - }); - } else { - const tabId = await getTabId(mail.source); - await chrome.tabs.update(tabId, { active: true }); - } - } else { - await reuseOrCreateTab(mail.source, mail.url, { - inject: mail.inject, - injectSource: mail.injectSource, - }); - } + await focusMailTabForVerification(mail, 4); await resolveVerificationStep(4, state, mail, { filterAfterTimestamp: stepStartedAt, - requestFreshCodeFirst: true, + initialMailboxRefreshFirst: mail.source === 'inbucket-mail', + initialMailboxRefreshDelayMs: 2000, }); return; } @@ -2305,7 +2361,7 @@ async function runStep7Attempt(state) { const authTabId = await getTabId('signup-page'); if (authTabId) { - await chrome.tabs.update(authTabId, { active: true }); + await activateTabWithRetry(authTabId, { logLabel: '步骤 7' }); } else { if (!state.oauthUrl) { throw new Error('缺少 OAuth 链接,请先完成步骤 1。'); @@ -2326,24 +2382,7 @@ async function runStep7Attempt(state) { } await addLog(`步骤 7:正在打开${mail.label}...`); - - const alive = await isTabAlive(mail.source); - if (alive) { - if (mail.navigateOnReuse) { - await reuseOrCreateTab(mail.source, mail.url, { - inject: mail.inject, - injectSource: mail.injectSource, - }); - } else { - const tabId = await getTabId(mail.source); - await chrome.tabs.update(tabId, { active: true }); - } - } else { - await reuseOrCreateTab(mail.source, mail.url, { - inject: mail.inject, - injectSource: mail.injectSource, - }); - } + await focusMailTabForVerification(mail, 7); await resolveVerificationStep(7, state, mail, { filterAfterTimestamp: stepStartedAt, diff --git a/content/inbucket-mail.js b/content/inbucket-mail.js index 1de59491..03eda500 100644 --- a/content/inbucket-mail.js +++ b/content/inbucket-mail.js @@ -136,7 +136,10 @@ function getCurrentMailboxIds() { } async function refreshMailbox() { - const refreshButton = document.querySelector('button[alt="Refresh Mailbox"]'); + const refreshButton = document.querySelector('button[alt="Refresh Mailbox"]') + || Array.from(document.querySelectorAll('.message-list-controls button')).find((button) => { + return button.querySelector('.fa-sync'); + }); if (!refreshButton) return; simulateClick(refreshButton); @@ -172,6 +175,8 @@ async function handleMailboxPollEmail(step, payload) { maxAttempts = 20, intervalMs = 3000, excludeCodes = [], + initialRefreshFirst = false, + initialRefreshDelayMs = 2000, } = payload || {}; const excludedCodeSet = new Set(excludeCodes.filter(Boolean)); @@ -192,7 +197,11 @@ async function handleMailboxPollEmail(step, payload) { for (let attempt = 1; attempt <= maxAttempts; attempt++) { log(`步骤 ${step}:正在轮询 Inbucket 邮箱,第 ${attempt}/${maxAttempts} 次`); - if (attempt > 1) { + if (attempt === 1 && initialRefreshFirst) { + log(`步骤 ${step}:先等待 ${Math.round(initialRefreshDelayMs / 1000)} 秒,再主动刷新邮箱检查新邮件...`); + await sleep(initialRefreshDelayMs); + await refreshMailbox(); + } else if (attempt > 1) { await refreshMailbox(); } From f678c79b1bc711189033f60edfdcf8c6e4be5353 Mon Sep 17 00:00:00 2001 From: xuvian Date: Sat, 11 Apr 2026 22:55:12 +0800 Subject: [PATCH 02/15] =?UTF-8?q?feat:=20=E8=8E=B7=E5=8F=96=20Duck=20?= =?UTF-8?q?=E9=82=AE=E7=AE=B1=E5=90=8E=E8=87=AA=E5=8A=A8=E5=88=87=E5=9B=9E?= =?UTF-8?q?=E6=B3=A8=E5=86=8C=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- background.js | 22 ++++++++++++++++++++-- sidepanel/sidepanel.js | 10 ++++++++-- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/background.js b/background.js index 29a8d3c4..325678be 100644 --- a/background.js +++ b/background.js @@ -1496,7 +1496,10 @@ async function executeStepAndWait(step, delayAfter = 2000) { async function fetchDuckEmail(options = {}) { throwIfStopped(); - const { generateNew = true } = options; + const { + generateNew = true, + returnToSignupPageOnSuccess = true, + } = options; await addLog(`Duck 邮箱:正在打开自动填充设置(${generateNew ? '生成新地址' : '复用当前地址'})...`); await reuseOrCreateTab('duck-mail', DUCK_AUTOFILL_URL); @@ -1515,6 +1518,18 @@ async function fetchDuckEmail(options = {}) { } await setEmailState(result.email); + + if (returnToSignupPageOnSuccess) { + const signupTabId = await getTabId('signup-page'); + if (signupTabId) { + try { + await activateTabWithRetry(signupTabId, { logLabel: 'Duck 邮箱' }); + } catch (err) { + console.warn(LOG_PREFIX, 'Failed to return to signup page after duck email fetch:', err?.message || err); + } + } + } + await addLog(`Duck 邮箱:${result.generated ? '已生成' : '已读取'} ${result.email}`, 'ok'); return result.email; } @@ -1572,7 +1587,10 @@ async function ensureAutoEmailReady(targetRun, totalRuns, attemptRuns) { if (duckAttempt > 1) { await addLog(`Duck 邮箱:正在进行第 ${duckAttempt}/${DUCK_EMAIL_MAX_ATTEMPTS} 次自动获取重试...`, 'warn'); } - const duckEmail = await fetchDuckEmail({ generateNew: true }); + const duckEmail = await fetchDuckEmail({ + generateNew: true, + returnToSignupPageOnSuccess: true, + }); await addLog(`=== 目标 ${targetRun}/${totalRuns} 轮:Duck 邮箱已就绪:${duckEmail}(第 ${attemptRuns} 次尝试,Duck 第 ${duckAttempt}/${DUCK_EMAIL_MAX_ATTEMPTS} 次获取)===`, 'ok'); return duckEmail; } catch (err) { diff --git a/sidepanel/sidepanel.js b/sidepanel/sidepanel.js index f9ded6f0..03874a9f 100644 --- a/sidepanel/sidepanel.js +++ b/sidepanel/sidepanel.js @@ -657,7 +657,10 @@ async function fetchDuckEmail(options = {}) { const response = await chrome.runtime.sendMessage({ type: 'FETCH_DUCK_EMAIL', source: 'sidepanel', - payload: { generateNew: true }, + payload: { + generateNew: true, + returnToSignupPageOnSuccess: true, + }, }); if (response?.error) { @@ -774,7 +777,10 @@ document.querySelectorAll('.step-btn').forEach(btn => { let email = inputEmail.value.trim(); if (!email) { try { - email = await fetchDuckEmail({ showFailureToast: false }); + email = await fetchDuckEmail({ + showFailureToast: false, + returnToSignupPageOnSuccess: true, + }); } catch (err) { showToast(`自动获取失败:${err.message},请手动粘贴邮箱后重试。`, 'warn'); return; From 79d33990fc397fd5debb59c2ced1962e25da00cf Mon Sep 17 00:00:00 2001 From: Isulew <224964+netcookies@users.noreply.github.com> Date: Sun, 12 Apr 2026 23:05:14 +0800 Subject: [PATCH 03/15] =?UTF-8?q?fix:=20=E5=85=BC=E5=AE=B9=E6=96=B0?= =?UTF-8?q?=E7=9A=84=20Codex=20OAuth=20=E5=90=8C=E6=84=8F=E9=A1=B5?= =?UTF-8?q?=E7=BB=93=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 扩展同意页文案匹配,覆盖新的 Sign in to Codex 提示 - 优先从 OAuth consent form 内定位 Continue 提交按钮 - 用 form action 和按钮状态辅助识别真正的同意页 --- content/signup-page.js | 57 +++++++++++++++++++++++++++++++++++++----- 1 file changed, 51 insertions(+), 6 deletions(-) diff --git a/content/signup-page.js b/content/signup-page.js index 6fcddaf0..73fe7b43 100644 --- a/content/signup-page.js +++ b/content/signup-page.js @@ -396,7 +396,9 @@ async function step3_fillEmailPassword(payload) { const INVALID_VERIFICATION_CODE_PATTERN = /代码不正确|验证码不正确|验证码错误|code\s+(?:is\s+)?incorrect|invalid\s+code|incorrect\s+code|try\s+again/i; const VERIFICATION_PAGE_PATTERN = /检查您的收件箱|输入我们刚刚向|重新发送电子邮件|重新发送验证码|验证码|代码不正确|email\s+verification/i; -const OAUTH_CONSENT_PAGE_PATTERN = /使用\s*ChatGPT\s*登录到\s*Codex|login\s+to\s+codex|log\s+in\s+to\s+codex|authorize|授权/i; +const OAUTH_CONSENT_PAGE_PATTERN = /使用\s*ChatGPT\s*登录到\s*Codex|sign\s+in\s+to\s+codex(?:\s+with\s+chatgpt)?|login\s+to\s+codex|log\s+in\s+to\s+codex|authorize|授权/i; +const OAUTH_CONSENT_FORM_SELECTOR = 'form[action*="/sign-in-with-chatgpt/" i][action*="/consent" i]'; +const CONTINUE_ACTION_PATTERN = /继续|continue/i; const ADD_PHONE_PAGE_PATTERN = /add[\s-]*phone|添加手机号|手机号码|手机号|phone\s+number|telephone/i; const STEP5_SUBMIT_ERROR_PATTERN = /无法根据该信息创建帐户|请重试|unable\s+to\s+create\s+(?:your\s+)?account|couldn'?t\s+create\s+(?:your\s+)?account|something\s+went\s+wrong|invalid\s+(?:birthday|birth|date)|生日|出生日期/i; const AUTH_TIMEOUT_ERROR_TITLE_PATTERN = /糟糕,出错了|something\s+went\s+wrong|oops/i; @@ -449,16 +451,60 @@ function getPageTextSnapshot() { .trim(); } +function getOAuthConsentForm() { + return document.querySelector(OAUTH_CONSENT_FORM_SELECTOR); +} + function getPrimaryContinueButton() { + const consentForm = getOAuthConsentForm(); + if (consentForm) { + const formButtons = Array.from( + consentForm.querySelectorAll('button[type="submit"], input[type="submit"], [role="button"]') + ); + + const formContinueButton = formButtons.find((el) => { + if (!isVisibleElement(el)) return false; + + const ddActionName = el.getAttribute?.('data-dd-action-name') || ''; + return ddActionName === 'Continue' || CONTINUE_ACTION_PATTERN.test(getActionText(el)); + }); + if (formContinueButton) { + return formContinueButton; + } + + const firstVisibleSubmit = formButtons.find(isVisibleElement); + if (firstVisibleSubmit) { + return firstVisibleSubmit; + } + } + const continueBtn = document.querySelector( - 'button[type="submit"][data-dd-action-name="Continue"], button[type="submit"]._primary_3rdp0_107' + `${OAUTH_CONSENT_FORM_SELECTOR} button[type="submit"], button[type="submit"][data-dd-action-name="Continue"], button[type="submit"]._primary_3rdp0_107` ); if (continueBtn && isVisibleElement(continueBtn)) { return continueBtn; } const buttons = document.querySelectorAll('button, [role="button"]'); - return Array.from(buttons).find((el) => isVisibleElement(el) && /继续|Continue/i.test(el.textContent || '')) || null; + return Array.from(buttons).find((el) => { + if (!isVisibleElement(el)) return false; + + const ddActionName = el.getAttribute?.('data-dd-action-name') || ''; + return ddActionName === 'Continue' || CONTINUE_ACTION_PATTERN.test(getActionText(el)); + }) || null; +} + +function isOAuthConsentPage() { + const pageText = getPageTextSnapshot(); + if (OAUTH_CONSENT_PAGE_PATTERN.test(pageText)) { + return true; + } + + if (getOAuthConsentForm()) { + return true; + } + + return /\bcodex\b/i.test(pageText) && /\bchatgpt\b/i.test(pageText) && Boolean(getPrimaryContinueButton()); } function isVerificationPageStillVisible() { @@ -493,7 +539,7 @@ function isStep8Ready() { if (isVerificationPageStillVisible()) return false; if (isAddPhonePageReady()) return false; - return OAUTH_CONSENT_PAGE_PATTERN.test(getPageTextSnapshot()); + return isOAuthConsentPage(); } function normalizeInlineText(text) { @@ -991,11 +1037,10 @@ async function step8_findAndClick() { } function getStep8State() { - const pageText = getPageTextSnapshot(); const continueBtn = getPrimaryContinueButton(); const state = { url: location.href, - consentPage: OAUTH_CONSENT_PAGE_PATTERN.test(pageText), + consentPage: isOAuthConsentPage(), consentReady: isStep8Ready(), verificationPage: isVerificationPageStillVisible(), addPhonePage: isAddPhonePageReady(), From 7bdd44b986e352679accdad3848ee090bec19a06 Mon Sep 17 00:00:00 2001 From: QLHazyCoder <2825305047@qq.com> Date: Mon, 13 Apr 2026 01:18:14 +0800 Subject: [PATCH 04/15] =?UTF-8?q?feat(sidepanel):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E8=8F=9C=E5=8D=95=EF=BC=8C=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E5=AF=BC=E5=85=A5=E5=92=8C=E5=AF=BC=E5=87=BA=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 1 + background.js | 231 +++++++++++++++++++++++------ sidepanel/sidepanel.css | 61 ++++++++ sidepanel/sidepanel.html | 9 ++ sidepanel/sidepanel.js | 303 ++++++++++++++++++++++++++++++++------- 5 files changed, 503 insertions(+), 102 deletions(-) diff --git a/README.md b/README.md index 584b08d6..b120186f 100644 --- a/README.md +++ b/README.md @@ -215,6 +215,7 @@ Cloudflare 模式下,插件不会再调用 Cloudflare API 创建路由。 - 手动输入:使用你自定义的密码 - 可通过输入框右侧的眼睛图标切换显示 - 配置会自动保存,也可以点击右侧 `保存` 按钮手动保存一次 +- 右上角 `配置` 按钮支持导出当前配置到 JSON 文件,也支持从 JSON 文件覆盖导入配置 扩展会把本轮实际使用的密码同步回侧边栏,便于查看和复制。 diff --git a/background.js b/background.js index ab1bf597..1902e12e 100644 --- a/background.js +++ b/background.js @@ -70,6 +70,8 @@ const PERSISTED_SETTING_DEFAULTS = { }; const PERSISTED_SETTING_KEYS = Object.keys(PERSISTED_SETTING_DEFAULTS); +const SETTINGS_EXPORT_SCHEMA_VERSION = 1; +const SETTINGS_EXPORT_FILENAME_PREFIX = 'multipage-settings'; const DEFAULT_STATE = { currentStep: 0, // 当前流程执行到的步骤编号。 @@ -141,6 +143,24 @@ function normalizeEmailGenerator(value = '') { return String(value || '').trim().toLowerCase() === 'cloudflare' ? 'cloudflare' : 'duck'; } +function normalizePanelMode(value = '') { + return String(value || '').trim().toLowerCase() === 'sub2api' ? 'sub2api' : 'cpa'; +} + +function normalizeMailProvider(value = '') { + const normalized = String(value || '').trim().toLowerCase(); + switch (normalized) { + case HOTMAIL_PROVIDER: + case '163': + case '163-vip': + case 'qq': + case 'inbucket': + return normalized; + default: + return PERSISTED_SETTING_DEFAULTS.mailProvider; + } +} + function normalizeLocalCpaStep9Mode(value = '') { return String(value || '').trim().toLowerCase() === 'bypass' ? 'bypass' @@ -157,18 +177,99 @@ function normalizeCloudflareDomain(rawValue = '') { return value; } +function normalizeCloudflareDomains(values) { + const normalizedDomains = []; + const seen = new Set(); + + for (const value of Array.isArray(values) ? values : []) { + const normalized = normalizeCloudflareDomain(value); + if (!normalized || seen.has(normalized)) continue; + seen.add(normalized); + normalizedDomains.push(normalized); + } + + return normalizedDomains; +} + +function normalizePersistentSettingValue(key, value) { + switch (key) { + case 'panelMode': + return normalizePanelMode(value); + case 'vpsUrl': + return String(value || '').trim(); + case 'vpsPassword': + return String(value || ''); + case 'localCpaStep9Mode': + return normalizeLocalCpaStep9Mode(value); + case 'sub2apiUrl': + return String(value || '').trim(); + case 'sub2apiEmail': + return String(value || '').trim(); + case 'sub2apiPassword': + return String(value || ''); + case 'sub2apiGroupName': + return String(value || '').trim(); + case 'customPassword': + return String(value || ''); + case 'autoRunSkipFailures': + case 'autoRunDelayEnabled': + return Boolean(value); + case 'autoRunDelayMinutes': + return normalizeAutoRunDelayMinutes(value); + case 'mailProvider': + return normalizeMailProvider(value); + case 'emailGenerator': + return normalizeEmailGenerator(value); + case 'inbucketHost': + return String(value || '').trim(); + case 'inbucketMailbox': + return String(value || '').trim(); + case 'cloudflareDomain': + return normalizeCloudflareDomain(value); + case 'cloudflareDomains': + return normalizeCloudflareDomains(value); + case 'hotmailAccounts': + return normalizeHotmailAccounts(value); + default: + return value; + } +} + +function buildPersistentSettingsPayload(input = {}, options = {}) { + const { fillDefaults = false, requireKnownKeys = false } = options; + if (!input || typeof input !== 'object' || Array.isArray(input)) { + throw new Error('\u914d\u7f6e\u5185\u5bb9\u683c\u5f0f\u65e0\u6548\u3002'); + } + + const payload = {}; + let matchedKeyCount = 0; + for (const key of PERSISTED_SETTING_KEYS) { + if (input[key] !== undefined) { + payload[key] = normalizePersistentSettingValue(key, input[key]); + matchedKeyCount += 1; + } else if (fillDefaults) { + payload[key] = normalizePersistentSettingValue(key, PERSISTED_SETTING_DEFAULTS[key]); + } + } + + if (requireKnownKeys && matchedKeyCount === 0) { + throw new Error('\u914d\u7f6e\u6587\u4ef6\u4e2d\u6ca1\u6709\u53ef\u8bc6\u522b\u7684\u914d\u7f6e\u5185\u5bb9\u3002'); + } + + if (payload.cloudflareDomains) { + const domains = normalizeCloudflareDomains(payload.cloudflareDomains); + if (payload.cloudflareDomain && !domains.includes(payload.cloudflareDomain)) { + domains.unshift(payload.cloudflareDomain); + } + payload.cloudflareDomains = domains; + } + + return payload; +} + async function getPersistedSettings() { const stored = await chrome.storage.local.get(PERSISTED_SETTING_KEYS); - return { - ...PERSISTED_SETTING_DEFAULTS, - ...stored, - autoRunSkipFailures: Boolean(stored.autoRunSkipFailures ?? PERSISTED_SETTING_DEFAULTS.autoRunSkipFailures), - autoRunDelayEnabled: Boolean(stored.autoRunDelayEnabled ?? PERSISTED_SETTING_DEFAULTS.autoRunDelayEnabled), - autoRunDelayMinutes: normalizeAutoRunDelayMinutes(stored.autoRunDelayMinutes ?? PERSISTED_SETTING_DEFAULTS.autoRunDelayMinutes), - emailGenerator: normalizeEmailGenerator(stored.emailGenerator ?? PERSISTED_SETTING_DEFAULTS.emailGenerator), - localCpaStep9Mode: normalizeLocalCpaStep9Mode(stored.localCpaStep9Mode ?? PERSISTED_SETTING_DEFAULTS.localCpaStep9Mode), - hotmailAccounts: normalizeHotmailAccounts(stored.hotmailAccounts), - }; + return buildPersistentSettingsPayload(stored, { fillDefaults: true }); } async function getState() { @@ -200,28 +301,75 @@ async function setState(updates) { } async function setPersistentSettings(updates) { - const persistedUpdates = {}; - for (const key of PERSISTED_SETTING_KEYS) { - if (updates[key] !== undefined) { - if (key === 'autoRunSkipFailures' || key === 'autoRunDelayEnabled') { - persistedUpdates[key] = Boolean(updates[key]); - } else if (key === 'autoRunDelayMinutes') { - persistedUpdates[key] = normalizeAutoRunDelayMinutes(updates[key]); - } else if (key === 'localCpaStep9Mode') { - persistedUpdates[key] = normalizeLocalCpaStep9Mode(updates[key]); - } else if (key === 'hotmailAccounts') { - persistedUpdates[key] = normalizeHotmailAccounts(updates[key]); - } else { - persistedUpdates[key] = updates[key]; - } - } - } + const persistedUpdates = buildPersistentSettingsPayload(updates); if (Object.keys(persistedUpdates).length > 0) { await chrome.storage.local.set(persistedUpdates); } } +function buildSettingsExportFilename(date = new Date()) { + const pad = (value) => String(value).padStart(2, '0'); + return `${SETTINGS_EXPORT_FILENAME_PREFIX}-${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}.json`; +} + +async function exportSettingsBundle() { + const settings = await getPersistedSettings(); + const bundle = { + schemaVersion: SETTINGS_EXPORT_SCHEMA_VERSION, + exportedAt: new Date().toISOString(), + extensionVersion: chrome.runtime.getManifest().version, + settings, + }; + + return { + fileName: buildSettingsExportFilename(), + fileContent: JSON.stringify(bundle, null, 2), + }; +} + +async function importSettingsBundle(configBundle) { + const state = await ensureManualInteractionAllowed('\u5bfc\u5165\u914d\u7f6e'); + if (Object.values(state.stepStatuses || {}).some((status) => status === 'running')) { + throw new Error('\u5f53\u524d\u6709\u6b65\u9aa4\u6b63\u5728\u6267\u884c\uff0c\u65e0\u6cd5\u5bfc\u5165\u914d\u7f6e\u3002'); + } + if (!configBundle || typeof configBundle !== 'object' || Array.isArray(configBundle)) { + throw new Error('\u914d\u7f6e\u6587\u4ef6\u5185\u5bb9\u65e0\u6548\u3002'); + } + + const schemaVersion = Number(configBundle.schemaVersion); + if (schemaVersion !== SETTINGS_EXPORT_SCHEMA_VERSION) { + throw new Error(`\u4ec5\u652f\u6301\u5bfc\u5165 schemaVersion=${SETTINGS_EXPORT_SCHEMA_VERSION} \u7684\u914d\u7f6e\u6587\u4ef6\u3002`); + } + if (!configBundle.settings || typeof configBundle.settings !== 'object' || Array.isArray(configBundle.settings)) { + throw new Error('\u914d\u7f6e\u6587\u4ef6\u7f3a\u5c11 settings \u914d\u7f6e\u6bb5\u3002'); + } + + const importedSettings = buildPersistentSettingsPayload(configBundle.settings, { + fillDefaults: true, + requireKnownKeys: true, + }); + + await setPersistentSettings(importedSettings); + + const sessionUpdates = { + ...importedSettings, + currentHotmailAccountId: null, + }; + if (importedSettings.mailProvider === HOTMAIL_PROVIDER) { + sessionUpdates.email = null; + } + + await setState(sessionUpdates); + broadcastDataUpdate({ + ...importedSettings, + currentHotmailAccountId: null, + ...(sessionUpdates.email !== undefined ? { email: sessionUpdates.email } : {}), + }); + + return getState(); +} + function broadcastDataUpdate(payload) { chrome.runtime.sendMessage({ type: 'DATA_UPDATED', @@ -2459,32 +2607,21 @@ async function handleMessage(message, sender) { } case 'SAVE_SETTING': { - const updates = {}; - if (message.payload.panelMode !== undefined) updates.panelMode = message.payload.panelMode; - if (message.payload.vpsUrl !== undefined) updates.vpsUrl = message.payload.vpsUrl; - if (message.payload.vpsPassword !== undefined) updates.vpsPassword = message.payload.vpsPassword; - if (message.payload.localCpaStep9Mode !== undefined) updates.localCpaStep9Mode = normalizeLocalCpaStep9Mode(message.payload.localCpaStep9Mode); - if (message.payload.sub2apiUrl !== undefined) updates.sub2apiUrl = message.payload.sub2apiUrl; - if (message.payload.sub2apiEmail !== undefined) updates.sub2apiEmail = message.payload.sub2apiEmail; - if (message.payload.sub2apiPassword !== undefined) updates.sub2apiPassword = message.payload.sub2apiPassword; - if (message.payload.sub2apiGroupName !== undefined) updates.sub2apiGroupName = message.payload.sub2apiGroupName; - if (message.payload.customPassword !== undefined) updates.customPassword = message.payload.customPassword; - if (message.payload.autoRunSkipFailures !== undefined) updates.autoRunSkipFailures = Boolean(message.payload.autoRunSkipFailures); - if (message.payload.autoRunDelayEnabled !== undefined) updates.autoRunDelayEnabled = Boolean(message.payload.autoRunDelayEnabled); - if (message.payload.autoRunDelayMinutes !== undefined) updates.autoRunDelayMinutes = normalizeAutoRunDelayMinutes(message.payload.autoRunDelayMinutes); - if (message.payload.mailProvider !== undefined) updates.mailProvider = message.payload.mailProvider; - if (message.payload.emailGenerator !== undefined) updates.emailGenerator = normalizeEmailGenerator(message.payload.emailGenerator); - if (message.payload.inbucketHost !== undefined) updates.inbucketHost = message.payload.inbucketHost; - if (message.payload.inbucketMailbox !== undefined) updates.inbucketMailbox = message.payload.inbucketMailbox; - if (message.payload.cloudflareDomain !== undefined) updates.cloudflareDomain = normalizeCloudflareDomain(message.payload.cloudflareDomain); - if (message.payload.cloudflareDomains !== undefined) updates.cloudflareDomains = Array.isArray(message.payload.cloudflareDomains) - ? message.payload.cloudflareDomains.map(domain => normalizeCloudflareDomain(domain)).filter(Boolean) - : []; + const updates = buildPersistentSettingsPayload(message.payload || {}); await setPersistentSettings(updates); await setState(updates); return { ok: true }; } + case 'EXPORT_SETTINGS': { + return { ok: true, ...(await exportSettingsBundle()) }; + } + + case 'IMPORT_SETTINGS': { + const state = await importSettingsBundle(message.payload?.config || null); + return { ok: true, state }; + } + case 'UPSERT_HOTMAIL_ACCOUNT': { const account = await upsertHotmailAccount(message.payload || {}); return { ok: true, account }; diff --git a/sidepanel/sidepanel.css b/sidepanel/sidepanel.css index 7b1a9a7d..b627d9d9 100644 --- a/sidepanel/sidepanel.css +++ b/sidepanel/sidepanel.css @@ -142,6 +142,67 @@ header { [data-theme="dark"] .theme-toggle .icon-moon { display: none; } [data-theme="dark"] .theme-toggle .icon-sun { display: block; } +.header-menu { + position: relative; + display: flex; + align-items: center; +} + +.header-config-btn { + min-width: 0; + padding-inline: 10px; +} + +.header-config-btn[aria-expanded="true"] { + background: var(--bg-hover); + color: var(--text-primary); +} + +.header-dropdown { + position: absolute; + top: calc(100% + 6px); + right: 0; + min-width: 120px; + padding: 6px; + display: flex; + flex-direction: column; + gap: 4px; + background: var(--bg-base); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + box-shadow: var(--shadow-md); + z-index: 1300; +} + +.header-dropdown[hidden] { + display: none !important; +} + +.header-dropdown-item { + width: 100%; + padding: 8px 10px; + border: none; + border-radius: var(--radius-sm); + background: transparent; + color: var(--text-secondary); + font: inherit; + font-size: 12px; + font-weight: 600; + text-align: left; + cursor: pointer; + transition: background var(--transition), color var(--transition); +} + +.header-dropdown-item:hover:not(:disabled) { + background: var(--bg-hover); + color: var(--text-primary); +} + +.header-dropdown-item:disabled { + color: var(--text-muted); + cursor: not-allowed; +} + /* ============================================================ Buttons ============================================================ */ diff --git a/sidepanel/sidepanel.html b/sidepanel/sidepanel.html index 04d6332c..af607f49 100644 --- a/sidepanel/sidepanel.html +++ b/sidepanel/sidepanel.html @@ -58,6 +58,14 @@

多页面

+
+ + +
@@ -343,6 +351,7 @@

多页面

+ diff --git a/sidepanel/sidepanel.js b/sidepanel/sidepanel.js index 8f9129be..918c5075 100644 --- a/sidepanel/sidepanel.js +++ b/sidepanel/sidepanel.js @@ -35,6 +35,12 @@ const autoScheduleMeta = document.getElementById('auto-schedule-meta'); const btnAutoRunNow = document.getElementById('btn-auto-run-now'); const btnAutoCancelSchedule = document.getElementById('btn-auto-cancel-schedule'); const btnClearLog = document.getElementById('btn-clear-log'); +const configMenuShell = document.getElementById('config-menu-shell'); +const btnConfigMenu = document.getElementById('btn-config-menu'); +const configMenu = document.getElementById('config-menu'); +const btnExportSettings = document.getElementById('btn-export-settings'); +const btnImportSettings = document.getElementById('btn-import-settings'); +const inputImportSettingsFile = document.getElementById('input-import-settings-file'); const selectPanelMode = document.getElementById('select-panel-mode'); const rowVpsUrl = document.getElementById('row-vps-url'); const inputVpsUrl = document.getElementById('input-vps-url'); @@ -120,6 +126,8 @@ let currentModalActions = []; let scheduledCountdownTimer = null; let hotmailActionInFlight = false; let hotmailListExpanded = false; +let configMenuOpen = false; +let configActionInFlight = false; const EYE_OPEN_ICON = ''; const EYE_CLOSED_ICON = ''; @@ -258,6 +266,71 @@ async function openConfirmModal({ title, message, confirmLabel = '确认', confi return choice === 'confirm'; } +function updateConfigMenuControls() { + const disabled = configActionInFlight || settingsSaveInFlight; + const importLocked = disabled + || currentAutoRun.autoRunning + || Object.values(getStepStatuses()).some((status) => status === 'running'); + if (btnConfigMenu) { + btnConfigMenu.disabled = disabled; + btnConfigMenu.setAttribute('aria-expanded', String(configMenuOpen)); + } + if (configMenu) { + configMenu.hidden = !configMenuOpen; + } + if (btnExportSettings) { + btnExportSettings.disabled = disabled; + } + if (btnImportSettings) { + btnImportSettings.disabled = importLocked; + } +} + +function closeConfigMenu() { + configMenuOpen = false; + updateConfigMenuControls(); +} + +function openConfigMenu() { + configMenuOpen = true; + updateConfigMenuControls(); +} + +function toggleConfigMenu() { + configMenuOpen ? closeConfigMenu() : openConfigMenu(); +} + +async function waitForSettingsSaveIdle() { + while (settingsSaveInFlight) { + await new Promise((resolve) => setTimeout(resolve, 50)); + } +} + +async function flushPendingSettingsBeforeExport() { + clearTimeout(settingsAutoSaveTimer); + await waitForSettingsSaveIdle(); + if (settingsDirty) { + await saveSettings({ silent: true }); + } +} + +async function settlePendingSettingsBeforeImport() { + clearTimeout(settingsAutoSaveTimer); + await waitForSettingsSaveIdle(); +} + +function downloadTextFile(content, fileName, mimeType = 'application/json;charset=utf-8') { + const blob = new Blob([content], { type: mimeType }); + const objectUrl = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = objectUrl; + anchor.download = fileName; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + setTimeout(() => URL.revokeObjectURL(objectUrl), 0); +} + function isDoneStatus(status) { return status === 'completed' || status === 'manual_completed' || status === 'skipped'; } @@ -600,6 +673,7 @@ function markSettingsDirty(isDirty = true) { function updateSaveButtonState() { btnSaveSettings.disabled = settingsSaveInFlight || !settingsDirty; + updateConfigMenuControls(); btnSaveSettings.textContent = settingsSaveInFlight ? '保存中' : '保存'; } @@ -707,6 +781,7 @@ function applyAutoRunStatus(payload = currentAutoRun) { updateAutoDelayInputState(); syncScheduledCountdownTicker(); updateStopButtonState(scheduled || paused || locked || Object.values(getStepStatuses()).some(status => status === 'running')); + updateConfigMenuControls(); } function initializeManualStepActions() { @@ -744,11 +819,45 @@ function initializeManualStepActions() { // State Restore on load // ============================================================ +function applySettingsState(state) { + syncLatestState(state); + syncAutoRunState(state); + + inputEmail.value = state?.email || ''; + syncPasswordField(state || {}); + inputVpsUrl.value = state?.vpsUrl || ''; + inputVpsPassword.value = state?.vpsPassword || ''; + setLocalCpaStep9Mode(state?.localCpaStep9Mode); + selectPanelMode.value = state?.panelMode || 'cpa'; + inputSub2ApiUrl.value = state?.sub2apiUrl || ''; + inputSub2ApiEmail.value = state?.sub2apiEmail || ''; + inputSub2ApiPassword.value = state?.sub2apiPassword || ''; + inputSub2ApiGroup.value = state?.sub2apiGroupName || ''; + selectMailProvider.value = state?.mailProvider || '163'; + selectEmailGenerator.value = state?.emailGenerator || 'duck'; + inputInbucketHost.value = state?.inbucketHost || ''; + inputInbucketMailbox.value = state?.inbucketMailbox || ''; + renderCloudflareDomainOptions(state?.cloudflareDomain || ''); + setCloudflareDomainEditMode(false, { clearInput: true }); + inputAutoSkipFailures.checked = Boolean(state?.autoRunSkipFailures); + inputAutoDelayEnabled.checked = Boolean(state?.autoRunDelayEnabled); + inputAutoDelayMinutes.value = String(normalizeAutoDelayMinutes(state?.autoRunDelayMinutes)); + if (state?.autoRunTotalRuns) { + inputRunCount.value = String(state.autoRunTotalRuns); + } + + applyAutoRunStatus(state); + markSettingsDirty(false); + updateAutoDelayInputState(); + updatePanelModeUI(); + updateMailProviderUI(); + updateButtonStates(); +} + async function restoreState() { try { const state = await chrome.runtime.sendMessage({ type: 'GET_STATE', source: 'sidepanel' }); - syncLatestState(state); - syncAutoRunState(state); + applySettingsState(state); if (state.oauthUrl) { displayOauthUrl.textContent = state.oauthUrl; @@ -758,53 +867,6 @@ async function restoreState() { displayLocalhostUrl.textContent = state.localhostUrl; displayLocalhostUrl.classList.add('has-value'); } - if (state.email) { - inputEmail.value = state.email; - } - syncPasswordField(state); - if (state.vpsUrl) { - inputVpsUrl.value = state.vpsUrl; - } - if (state.vpsPassword) { - inputVpsPassword.value = state.vpsPassword; - } - setLocalCpaStep9Mode(state.localCpaStep9Mode); - if (state.panelMode) { - selectPanelMode.value = state.panelMode; - } - if (state.sub2apiUrl) { - inputSub2ApiUrl.value = state.sub2apiUrl; - } - if (state.sub2apiEmail) { - inputSub2ApiEmail.value = state.sub2apiEmail; - } - if (state.sub2apiPassword) { - inputSub2ApiPassword.value = state.sub2apiPassword; - } - if (state.sub2apiGroupName) { - inputSub2ApiGroup.value = state.sub2apiGroupName; - } - if (state.mailProvider) { - selectMailProvider.value = state.mailProvider; - } - if (state.emailGenerator) { - selectEmailGenerator.value = state.emailGenerator; - } - if (state.inbucketHost) { - inputInbucketHost.value = state.inbucketHost; - } - if (state.inbucketMailbox) { - inputInbucketMailbox.value = state.inbucketMailbox; - } - renderCloudflareDomainOptions(state.cloudflareDomain || ''); - setCloudflareDomainEditMode(false, { clearInput: true }); - inputAutoSkipFailures.checked = Boolean(state.autoRunSkipFailures); - inputAutoDelayEnabled.checked = Boolean(state.autoRunDelayEnabled); - inputAutoDelayMinutes.value = String(normalizeAutoDelayMinutes(state.autoRunDelayMinutes)); - if (state.autoRunTotalRuns) { - inputRunCount.value = String(state.autoRunTotalRuns); - } - if (state.stepStatuses) { for (const [step, status] of Object.entries(state.stepStatuses)) { updateStepUI(Number(step), status); @@ -817,14 +879,8 @@ async function restoreState() { } } - applyAutoRunStatus(state); - markSettingsDirty(false); - updateAutoDelayInputState(); updateStatusDisplay(latestState); updateProgressCounter(); - updatePanelModeUI(); - updateMailProviderUI(); - updateButtonStates(); } catch (err) { console.error('Failed to restore state:', err); } @@ -1177,6 +1233,7 @@ function updateStepUI(step, status) { updateButtonStates(); updateProgressCounter(); + updateConfigMenuControls(); } function updateProgressCounter() { @@ -1397,6 +1454,93 @@ async function copyTextToClipboard(text) { await navigator.clipboard.writeText(value); } +async function exportSettingsFile() { + closeConfigMenu(); + configActionInFlight = true; + updateConfigMenuControls(); + + try { + await flushPendingSettingsBeforeExport(); + const response = await chrome.runtime.sendMessage({ + type: 'EXPORT_SETTINGS', + source: 'sidepanel', + payload: {}, + }); + + if (response?.error) { + throw new Error(response.error); + } + if (!response?.fileContent || !response?.fileName) { + throw new Error('\u672a\u751f\u6210\u53ef\u4e0b\u8f7d\u7684\u914d\u7f6e\u6587\u4ef6\u3002'); + } + + downloadTextFile(response.fileContent, response.fileName); + showToast('\u914d\u7f6e\u5df2\u5bfc\u51fa\uff1a' + response.fileName, 'success', 2200); + } catch (err) { + showToast('\u5bfc\u51fa\u914d\u7f6e\u5931\u8d25\uff1a' + err.message, 'error'); + } finally { + configActionInFlight = false; + updateConfigMenuControls(); + } +} + +async function importSettingsFromFile(file) { + if (!file) return; + + configActionInFlight = true; + closeConfigMenu(); + updateConfigMenuControls(); + + try { + await settlePendingSettingsBeforeImport(); + const rawText = await file.text(); + + let parsedConfig = null; + try { + parsedConfig = JSON.parse(rawText); + } catch { + throw new Error('\u914d\u7f6e\u6587\u4ef6\u4e0d\u662f\u6709\u6548\u7684 JSON\u3002'); + } + + const confirmed = await openConfirmModal({ + title: '\u5bfc\u5165\u914d\u7f6e', + message: '\u786e\u8ba4\u5bfc\u5165\u914d\u7f6e\u6587\u4ef6 "' + file.name + '" \u5417\uff1f\u5bfc\u5165\u540e\u4f1a\u8986\u76d6\u5f53\u524d\u914d\u7f6e\u3002', + confirmLabel: '\u786e\u8ba4\u8986\u76d6\u5bfc\u5165', + confirmVariant: 'btn-danger', + }); + if (!confirmed) { + return; + } + + const response = await chrome.runtime.sendMessage({ + type: 'IMPORT_SETTINGS', + source: 'sidepanel', + payload: { + config: parsedConfig, + }, + }); + + if (response?.error) { + throw new Error(response.error); + } + if (!response?.state) { + throw new Error('\u5bfc\u5165\u540e\u672a\u8fd4\u56de\u6700\u65b0\u914d\u7f6e\u72b6\u6001\u3002'); + } + + applySettingsState(response.state); + updateStatusDisplay(latestState); + showToast('\u914d\u7f6e\u5df2\u5bfc\u5165\uff0c\u5f53\u524d\u914d\u7f6e\u5df2\u8986\u76d6\u3002', 'success', 2200); + } catch (err) { + showToast('\u5bfc\u5165\u914d\u7f6e\u5931\u8d25\uff1a' + err.message, 'error'); + } finally { + configActionInFlight = false; + updateConfigMenuControls(); + if (inputImportSettingsFile) { + inputImportSettingsFile.value = ''; + } + } +} + async function deleteHotmailAccountsByMode(mode) { const isUsedMode = mode === 'used'; const targetAccounts = getHotmailAccountsByUsage(isUsedMode ? 'used' : 'all'); @@ -1838,6 +1982,38 @@ btnStop.addEventListener('click', async () => { showToast(isAutoRunScheduledPhase() ? '正在取消倒计时计划...' : '正在停止当前流程...', 'warn', 2000); }); +btnConfigMenu?.addEventListener('click', (event) => { + event.stopPropagation(); + toggleConfigMenu(); +}); + +configMenu?.addEventListener('click', (event) => { + event.stopPropagation(); +}); + +btnExportSettings?.addEventListener('click', async () => { + if (configActionInFlight || settingsSaveInFlight) { + return; + } + await exportSettingsFile(); +}); + +btnImportSettings?.addEventListener('click', async () => { + if (configActionInFlight || settingsSaveInFlight) { + return; + } + closeConfigMenu(); + if (inputImportSettingsFile) { + inputImportSettingsFile.value = ''; + inputImportSettingsFile.click(); + } +}); + +inputImportSettingsFile?.addEventListener('change', async () => { + const file = inputImportSettingsFile.files?.[0] || null; + await importSettingsFromFile(file); +}); + autoStartModal?.addEventListener('click', (event) => { if (event.target === autoStartModal) { resolveModalChoice(null); @@ -2280,6 +2456,22 @@ btnTheme.addEventListener('click', () => { setTheme(current === 'dark' ? 'light' : 'dark'); }); +document.addEventListener('click', (event) => { + if (!configMenuOpen) { + return; + } + if (configMenuShell?.contains(event.target)) { + return; + } + closeConfigMenu(); +}); + +document.addEventListener('keydown', (event) => { + if (event.key === 'Escape' && configMenuOpen) { + closeConfigMenu(); + } +}); + // ============================================================ // Init // ============================================================ @@ -2288,6 +2480,7 @@ initializeManualStepActions(); initTheme(); initHotmailListExpandedState(); updateSaveButtonState(); +updateConfigMenuControls(); setLocalCpaStep9Mode(DEFAULT_LOCAL_CPA_STEP9_MODE); restoreState().then(() => { syncPasswordToggleLabel(); From f719ba8d29a5ccb553fc93ab046e4b2fb93c6b1d Mon Sep 17 00:00:00 2001 From: QLHazyCoder <2825305047@qq.com> Date: Mon, 13 Apr 2026 01:47:51 +0800 Subject: [PATCH 05/15] =?UTF-8?q?feat(email-generator):=20=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=20Cloudflare=20=E9=82=AE=E7=AE=B1=E7=94=9F=E6=88=90?= =?UTF-8?q?=E9=80=BB=E8=BE=91=EF=BC=8C=E4=BD=BF=E7=94=A8=2010=20=E4=BD=8D?= =?UTF-8?q?=E9=9A=8F=E6=9C=BA=E5=89=8D=E7=BC=80=E6=A0=BC=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 7 ++++--- background.js | 29 ++++++++++++++++++----------- 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index b120186f..33a1a9bd 100644 --- a/README.md +++ b/README.md @@ -182,9 +182,10 @@ Cloudflare 模式下,插件不会再调用 Cloudflare API 创建路由。 它现在只做一件事: 1. 根据你当前选中的 `CF 域名` -2. 本地生成一个随机前缀 -3. 直接得到一个类似 `user20260412153000123@example.xyz` 的注册邮箱 -4. 把这个邮箱写入当前流程继续往下跑 +2. 本地生成一个 10 位随机前缀 +3. 前缀由 `6 个小写字母 + 4 个数字` 组成,顺序随机打乱 +4. 直接得到一个类似 `a3b9cd1e2f@example.xyz` 的注册邮箱 +5. 把这个邮箱写入当前流程继续往下跑 也就是说,插件默认认为: diff --git a/background.js b/background.js index 1902e12e..3657472a 100644 --- a/background.js +++ b/background.js @@ -3027,17 +3027,24 @@ function getEmailGeneratorLabel(generator) { } function generateCloudflareAliasLocalPart() { - const now = new Date(); - const stamp = [ - now.getFullYear(), - String(now.getMonth() + 1).padStart(2, '0'), - String(now.getDate()).padStart(2, '0'), - String(now.getHours()).padStart(2, '0'), - String(now.getMinutes()).padStart(2, '0'), - String(now.getSeconds()).padStart(2, '0'), - ].join(''); - const randomPart = String(Math.floor(Math.random() * 900) + 100); - return `user${stamp}${randomPart}`.toLowerCase(); + const letters = 'abcdefghijklmnopqrstuvwxyz'; + const digits = '0123456789'; + const chars = []; + + for (let i = 0; i < 6; i++) { + chars.push(letters[Math.floor(Math.random() * letters.length)]); + } + + for (let i = 0; i < 4; i++) { + chars.push(digits[Math.floor(Math.random() * digits.length)]); + } + + for (let i = chars.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [chars[i], chars[j]] = [chars[j], chars[i]]; + } + + return chars.join(''); } async function fetchCloudflareEmail(state, options = {}) { From 21d30f7f16a3f12b298fee68f856d3b8ae51d951 Mon Sep 17 00:00:00 2001 From: QLHazyCoder <2825305047@qq.com> Date: Mon, 13 Apr 2026 01:56:04 +0800 Subject: [PATCH 06/15] chore(release): bump version to 6.0.0 --- manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/manifest.json b/manifest.json index 6d09a82f..7a030ab9 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "多页面自动化", - "version": "5.0.0", + "version": "6.0.0", "description": "用于自动执行多步骤 OAuth 注册流程", "permissions": [ "sidePanel", From b3208486f63e9d56817a25eabf62edd13bd23a27 Mon Sep 17 00:00:00 2001 From: QLHazyCoder <2825305047@qq.com> Date: Mon, 13 Apr 2026 02:35:17 +0800 Subject: [PATCH 07/15] =?UTF-8?q?feat(readme):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E5=BF=AB=E9=80=9F=E5=BC=80=E5=A7=8B=E9=85=8D=E7=BD=AE=E8=AF=B4?= =?UTF-8?q?=E6=98=8E=EF=BC=8C=E6=9B=B4=E6=96=B0=E9=82=AE=E7=AE=B1=E7=94=9F?= =?UTF-8?q?=E6=88=90=E6=96=B9=E6=A1=88=E5=92=8C=20Cloudflare=20=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E6=8C=87=E5=AF=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 102 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 100 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 33a1a9bd..ff70f0b7 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,36 @@ 4. 选择本项目目录 5. 打开扩展侧边栏 +## 快速开始 + +如果你只是想先跑通一套最稳的组合,建议直接按下面三种方案之一配置。 + +### 方案 A:`CPA + QQ / 163 / 163 VIP` + +1. `CPA` 填你的管理面板 OAuth 页面地址 +2. `Mail` 选择 `QQ Mail`、`163 Mail` 或 `163 VIP Mail` +3. `邮箱生成` 选择 `DuckDuckGo` 或 `Cloudflare` +4. 若你选择 `Cloudflare`,先按下文把 Cloudflare Email Routing 配好 +5. 点击 `获取` 生成邮箱,或手动粘贴一个你能收信的邮箱 +6. 先单步验证 `Step 1 ~ Step 4` +7. 验证没问题后再点右上角 `Auto` + +### 方案 B:`SUB2API + QQ / 163 / 163 VIP` + +1. `来源` 选择 `SUB2API` +2. 填好 `SUB2API` 地址、登录邮箱、登录密码、分组名 +3. `Mail` 与 `邮箱生成` 的配置方式同方案 A +4. Step 1 会直接在 SUB2API 后台生成 OAuth 链接 +5. Step 9 会把 localhost 回调提交回 SUB2API,并直接创建 OpenAI 账号 + +### 方案 C:`Hotmail 账号池` + +1. `Mail` 选择 `Hotmail` +2. 在 `Hotmail 账号池` 中添加 `邮箱 / Client ID / Refresh Token` +3. 先点 `校验`,再点 `测试收信` +4. 通过后再执行步骤或 `Auto` +5. 当前项目中,`Mail = Hotmail` 时会直接使用账号池里的邮箱作为注册邮箱,不再走 `Duck / Cloudflare` 自动生成 + ## 侧边栏配置说明 ### `CPA` @@ -87,17 +117,18 @@ Step 1 和 Step 9 都依赖这个地址。 ### `Mail` -支持四种验证码来源: +支持五种验证码来源: - `Hotmail` - `163 Mail` +- `163 VIP Mail` - `QQ Mail` - `Inbucket` 说明: - `Hotmail` 通过侧边栏里的 Hotmail 账号池选择账号,并直接访问 Microsoft Graph 邮件接口 -- `QQ` 和 `163` 用于直接轮询网页邮箱 +- `QQ`、`163`、`163 VIP` 用于直接轮询网页邮箱 - `Inbucket` 通过你在侧边栏里配置的 host 访问 `mailbox` 页面:`https:///m//` ### `Hotmail 账号池` @@ -166,6 +197,8 @@ Step 3 使用的注册邮箱。 - `CF 域名` 支持保存多个,并通过下拉框切换当前要生成的域名 - Cloudflare 侧的转发规则、Catch-all、路由目标邮箱等,都需要你自己提前在 Cloudflare 后台配置好 - 当 `Mail = Hotmail` 时,这个输入框由账号池自动同步当前账号邮箱 +- 当 `Mail = Hotmail` 时,Step 3 会直接使用 Hotmail 账号池里的邮箱;`Duck / Cloudflare` 不参与自动邮箱生成 +- 若你准备走 `Cloudflare`,更推荐把 `Mail` 设为 `QQ / 163 / 163 VIP`;`Inbucket` 仅在它能真实接收外部邮件并完成 Cloudflare 验证时再使用 - 当前 `Auto` 按钮只负责 DuckDuckGo 地址获取 - 如果你使用 Inbucket,它只是验证码收件箱,不会自动生成 Inbucket 地址 @@ -201,6 +234,61 @@ Cloudflare 模式下,插件不会再调用 Cloudflare API 创建路由。 否则插件虽然能生成 `@你的域名` 邮箱,但验证码邮件最后没人接收,后面的 Step 4 / Step 7 还是会失败。 +#### 推荐搭配 + +- `Mail = QQ Mail`:Cloudflare 的 `Destination address / Destination addresses` 填你的 QQ 邮箱全地址 +- `Mail = 163 Mail`:Cloudflare 的 `Destination address / Destination addresses` 填你的 163 邮箱全地址 +- `Mail = 163 VIP Mail`:Cloudflare 的 `Destination address / Destination addresses` 填你的 163 VIP 邮箱全地址 +- `Mail = Inbucket`:仅当你的 Inbucket 实例本身就是一个真实可收外部邮件、且能收到 Cloudflare 验证邮件的地址时再使用 +- `Mail = Hotmail`:当前项目的自动流程不推荐和 Cloudflare 同时使用;因为 `Mail = Hotmail` 时,注册邮箱会直接使用 Hotmail 账号池邮箱 + +#### Cloudflare 后台怎么配(按钮中英对照) + +下面按钮名称以 Cloudflare 官方英文界面为准,括号内中文仅用于对照理解,不保证是 Cloudflare 的官方中文翻译。 + +1. 登录 Cloudflare 后台,选中你要用的域名 +2. 进入 `Email > Email Routing` +3. 如果这是你第一次给这个域名启用 Email Routing: + - 先检查 Cloudflare 准备添加的记录 + - 点击 `Add records and enable(添加记录并启用)` +4. 进入 `Routing rules(路由规则)` 或 `Routes(路由)` +5. 先创建一个固定地址,用来把目标收件箱加进 Cloudflare: + - 点击 `Create address(创建地址)` + - 在 `Custom address(自定义地址)` 里填一个固定前缀,例如 `cf-init` + - 在 `Action(动作)` 中选择 `Send to an email(转发到邮箱)` + - 在 `Destination / Destination addresses(目标邮箱)` 中填你真正收验证码的邮箱 + - 点击 `Save(保存)` +6. 打开 Cloudflare 发到目标邮箱的验证邮件,依次点击: + - `Verify email address(验证邮箱地址)` + - `Go to Email Routing(前往 Email Routing)` +7. 回到 Cloudflare 后台后,确认这个目标邮箱的状态已经变成 `Verified(已验证)` +8. 如果 Cloudflare 还在首次启用向导里要求继续: + - 点击 `Continue(继续)` + - 点击 `Add records and finish(添加记录并完成)` +9. 对于本项目这种“每次都生成随机前缀”的用法,建议再打开: + - `Catch-all address(Catch-all 地址)` + - 让它显示为 `Active(启用)` + - 在 `Action(动作)` 中选择 `Send to an email(转发到邮箱)` + - 如果界面要求选择 `Destination(目标邮箱)`,就选你刚刚已经验证通过的那个邮箱 + - 点击 `Save(保存)` +10. 最后再回到插件: + - `邮箱生成` 选择 `Cloudflare` + - 在 `CF 域名` 里点 `添加` + - 输入域名后点 `保存` + - 点击 `获取` + +#### Cloudflare 配好后怎么自测 + +1. 先在插件里点击 `获取`,拿到一个随机前缀邮箱 +2. 用另一个邮箱给这个地址发一封测试邮件 +3. 不要用目标邮箱给自己发测试邮件,否则某些邮箱服务会把它当成重复邮件直接吞掉 +4. 如果你的 `Mail` 选的是 `QQ / 163 / 163 VIP / Inbucket`,就去对应收件链路里确认这封测试邮件能否到达 + +#### 官方参考 + +- Cloudflare Email Routing 启用流程: +- Cloudflare Routing rules / Routes / Catch-all / Destination addresses: + #### 最简单的使用方式 1. 在 Cloudflare 后台先把你的域名收件转发规则配好 @@ -517,6 +605,16 @@ sidepanel/ 侧边栏 UI - CPA 面板 DOM 也需要和当前脚本选择器匹配 - `Auto` 按钮名称和 Step 8 的旧文案还未完全统一,但代码行为以实际实现为准 +## Star History + + + + + + Star History Chart + + + ## 调试建议 - 打开扩展侧边栏看日志 From bd8a34a4cc030f5443bf2b5f453dd8426ab935a3 Mon Sep 17 00:00:00 2001 From: QLHazyCoder <2825305047@qq.com> Date: Mon, 13 Apr 2026 02:44:11 +0800 Subject: [PATCH 08/15] chore: release v6.1.0 --- manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/manifest.json b/manifest.json index 7a030ab9..7d1898eb 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "多页面自动化", - "version": "6.0.0", + "version": "6.1.0", "description": "用于自动执行多步骤 OAuth 注册流程", "permissions": [ "sidePanel", From 328b436b542dbf9e1eafbb6612a7f1b624958dd9 Mon Sep 17 00:00:00 2001 From: QLHazyCoder <2825305047@qq.com> Date: Mon, 13 Apr 2026 02:55:21 +0800 Subject: [PATCH 09/15] docs: align star history timeline and scale --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index ff70f0b7..7c5292dd 100644 --- a/README.md +++ b/README.md @@ -607,11 +607,11 @@ sidepanel/ 侧边栏 UI ## Star History - + - - - Star History Chart + + + Star History Chart From 9108a4d54b645074e7d33706ea64ebff2aead09d Mon Sep 17 00:00:00 2001 From: QLHazyCoder <2825305047@qq.com> Date: Mon, 13 Apr 2026 03:02:59 +0800 Subject: [PATCH 10/15] docs: move star history before capability section --- README.md | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 7c5292dd..5c606bfb 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,16 @@ +## Star History + + + + + + Star History Chart + + + ## 当前能力 - 从 CPA 面板自动获取 OpenAI OAuth 授权链接 @@ -605,16 +615,6 @@ sidepanel/ 侧边栏 UI - CPA 面板 DOM 也需要和当前脚本选择器匹配 - `Auto` 按钮名称和 Step 8 的旧文案还未完全统一,但代码行为以实际实现为准 -## Star History - - - - - - Star History Chart - - - ## 调试建议 - 打开扩展侧边栏看日志 From d7bccf796fce08471061971bfa1c3fe8b508d745 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 12 Apr 2026 19:16:43 +0000 Subject: [PATCH 11/15] Initial plan From 6faf251a8bc78f4ab61f78def2b0697902fb7ffe Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 12 Apr 2026 19:35:52 +0000 Subject: [PATCH 12/15] fix: restore buildLocalhostCleanupPrefix/closeTabsByUrlPrefix, fix test mocks, improve Hotmail AADSTS90023 error Agent-Logs-Url: https://github.com/QLHazyCoder/codex-oauth-automation-extension/sessions/2111e544-f823-4faa-b8b6-57f506fe45ac Co-authored-by: QLHazyCoder <109800873+QLHazyCoder@users.noreply.github.com> --- background.js | 31 ++++++++++++++++++++- tests/step8-stop-cleanup.test.js | 6 ++++ tests/step9-localhost-cleanup-scope.test.js | 2 ++ 3 files changed, 38 insertions(+), 1 deletion(-) diff --git a/background.js b/background.js index 3657472a..52510c63 100644 --- a/background.js +++ b/background.js @@ -769,6 +769,7 @@ async function refreshHotmailAccessToken(account) { formData.set('grant_type', 'refresh_token'); formData.set('refresh_token', account.refreshToken); formData.set('scope', scopes.join(' ')); + formData.set('redirect_uri', 'https://login.microsoftonline.com/common/oauth2/nativeclient'); let response; try { @@ -801,7 +802,11 @@ async function refreshHotmailAccessToken(account) { } if (!response.ok || !payload?.access_token) { - const errorText = payload?.error_description || payload?.error?.message || payload?.error || payload?.message || text || `HTTP ${response.status}`; + const rawErrorText = payload?.error_description || payload?.error?.message || payload?.error || payload?.message || text || `HTTP ${response.status}`; + const isCrossOriginError = typeof rawErrorText === 'string' && rawErrorText.includes('AADSTS90023'); + const errorText = isCrossOriginError + ? `Azure AD 拒绝了跨域令牌请求(AADSTS90023)。请在 Azure AD 应用注册中将应用平台改为"单页应用程序(SPA)",并将重定向 URI 设置为 https://login.microsoftonline.com/common/oauth2/nativeclient,或将应用类型改为"移动和桌面应用程序(Native)"。` + : rawErrorText; const error = new Error(`Hotmail 令牌刷新失败:${errorText}`); error.code = 'HOTMAIL_TOKEN_REFRESH_FAILED'; throw error; @@ -1263,6 +1268,30 @@ async function closeLocalhostCallbackTabs(callbackUrl, options = {}) { return matchedIds.length; } +function buildLocalhostCleanupPrefix(rawUrl) { + if (!isLocalhostOAuthCallbackUrl(rawUrl)) return ''; + const parsed = parseUrlSafely(rawUrl); + return parsed ? `${parsed.origin}/auth` : ''; +} + +async function closeTabsByUrlPrefix(prefix, options = {}) { + if (!prefix) return 0; + + const { excludeTabIds = [] } = options; + const excluded = new Set(excludeTabIds.filter(id => Number.isInteger(id))); + const tabs = await chrome.tabs.query({}); + const matchedIds = tabs + .filter((tab) => Number.isInteger(tab.id) && !excluded.has(tab.id)) + .filter((tab) => typeof tab.url === 'string' && tab.url.startsWith(prefix)) + .map((tab) => tab.id); + + if (!matchedIds.length) return 0; + + await chrome.tabs.remove(matchedIds).catch(() => { }); + await addLog(`已关闭 ${matchedIds.length} 个匹配 ${prefix} 的 localhost 残留标签页。`, 'info'); + return matchedIds.length; +} + async function pingContentScriptOnTab(tabId) { if (!Number.isInteger(tabId)) return null; diff --git a/tests/step8-stop-cleanup.test.js b/tests/step8-stop-cleanup.test.js index f770a14a..baa018c3 100644 --- a/tests/step8-stop-cleanup.test.js +++ b/tests/step8-stop-cleanup.test.js @@ -125,6 +125,12 @@ async function addLog() {} async function broadcastStopToContentScripts() {} async function markRunningStepsStopped() {} async function broadcastAutoRunStatus() {} +async function getState() { + return { autoRunning: false }; +} +function isAutoRunScheduledState() { + return false; +} function getStep8CallbackUrlFromNavigation() { return ''; } function getStep8CallbackUrlFromTabUpdate() { return ''; } async function completeStepFromBackground() {} diff --git a/tests/step9-localhost-cleanup-scope.test.js b/tests/step9-localhost-cleanup-scope.test.js index edd73198..8b550ddc 100644 --- a/tests/step9-localhost-cleanup-scope.test.js +++ b/tests/step9-localhost-cleanup-scope.test.js @@ -56,6 +56,8 @@ const bundle = [ extractFunction('isLocalhostOAuthCallbackUrl'), extractFunction('isLocalhostOAuthCallbackTabMatch'), extractFunction('closeLocalhostCallbackTabs'), + extractFunction('buildLocalhostCleanupPrefix'), + extractFunction('closeTabsByUrlPrefix'), extractFunction('handleStepData'), ].join('\n'); From 750c9e602e1140e66a048c9db648e29ffe2981c7 Mon Sep 17 00:00:00 2001 From: xuvian Date: Mon, 13 Apr 2026 03:43:47 +0800 Subject: [PATCH 13/15] chore(ui): update gitignore entries --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index e3358c29..60f896d8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ /docs/md /.github +.spec-workflow +.claude From 826a7b26f5f6b6b8c8f04eb8bfe99e19144b30ba Mon Sep 17 00:00:00 2001 From: QLHazyCoder <2825305047@qq.com> Date: Mon, 13 Apr 2026 10:55:20 +0800 Subject: [PATCH 14/15] test: add verification stop propagation tests for error handling --- background.js | 20 ++- tests/verification-stop-propagation.test.js | 186 ++++++++++++++++++++ 2 files changed, 205 insertions(+), 1 deletion(-) create mode 100644 tests/verification-stop-propagation.test.js diff --git a/background.js b/background.js index fdcd07d9..e234edf7 100644 --- a/background.js +++ b/background.js @@ -1712,23 +1712,27 @@ async function reuseOrCreateTab(source, url, options = {}) { // ============================================================ async function sendToContentScript(source, message, options = {}) { + throwIfStopped(); const { responseTimeoutMs = getContentScriptResponseTimeoutMs(message) } = options; const registry = await getTabRegistry(); const entry = registry[source]; if (!entry || !entry.ready) { + throwIfStopped(); console.log(LOG_PREFIX, `${source} not ready, queuing command`); return queueCommand(source, message); } // Verify tab is still alive const alive = await isTabAlive(source); + throwIfStopped(); if (!alive) { // Tab was closed — queue the command, it will be sent when tab is reopened console.log(LOG_PREFIX, `${source} tab was closed, queuing command`); return queueCommand(source, message); } + throwIfStopped(); console.log(LOG_PREFIX, `Sending to ${source} (tab ${entry.tabId}):`, message.type); return sendTabMessageWithTimeout(entry.tabId, source, message, responseTimeoutMs); } @@ -3810,13 +3814,17 @@ function getVerificationPollPayload(step, state, overrides = {}) { } async function requestVerificationCodeResend(step) { + throwIfStopped(); const signupTabId = await getTabId('signup-page'); if (!signupTabId) { throw new Error('认证页面标签页已关闭,无法重新请求验证码。'); } + throwIfStopped(); await chrome.tabs.update(signupTabId, { active: true }); + throwIfStopped(); await addLog(`步骤 ${step}:正在请求新的${getVerificationCodeLabel(step)}验证码...`, 'warn'); + throwIfStopped(); const result = await sendToContentScript('signup-page', { type: 'RESEND_VERIFICATION_CODE', @@ -3863,6 +3871,7 @@ async function pollFreshVerificationCode(step, state, mail, pollOverrides = {}) const maxRounds = pollOverrides.maxRounds || VERIFICATION_POLL_MAX_ROUNDS; for (let round = 1; round <= maxRounds; round++) { + throwIfStopped(); if (round > 1) { await requestVerificationCodeResend(step); } @@ -3902,6 +3911,9 @@ async function pollFreshVerificationCode(step, state, mail, pollOverrides = {}) return result; } catch (err) { + if (isStopError(err)) { + throw err; + } lastError = err; await addLog(`步骤 ${step}:${err.message}`, 'warn'); if (round < maxRounds) { @@ -3963,7 +3975,7 @@ async function resolveVerificationStep(step, state, mail, options = {}) { await requestVerificationCodeResend(step); await addLog(`步骤 ${step}:已先请求一封新的${getVerificationCodeLabel(step)}验证码,再开始轮询邮箱。`, 'warn'); } catch (err) { - if (step === 7 && isStep7RestartFromStep6Error(err)) { + if (isStopError(err) || (step === 7 && isStep7RestartFromStep6Error(err))) { throw err; } await addLog(`步骤 ${step}:首次重新获取验证码失败:${err.message},将继续使用当前时间窗口轮询。`, 'warn'); @@ -3984,7 +3996,9 @@ async function resolveVerificationStep(step, state, mail, options = {}) { filterAfterTimestamp: nextFilterAfterTimestamp ?? undefined, }); + throwIfStopped(); await addLog(`步骤 ${step}:已获取${getVerificationCodeLabel(step)}验证码:${result.code}`); + throwIfStopped(); const submitResult = await submitVerificationCode(step, result.code); if (submitResult.invalidCode) { @@ -4023,6 +4037,7 @@ async function executeStep4(state) { } await chrome.tabs.update(signupTabId, { active: true }); + throwIfStopped(); await addLog('步骤 4:正在确认注册验证码页面是否就绪,必要时自动恢复密码页超时报错...'); const prepareResult = await sendToContentScriptResilient( 'signup-page', @@ -4050,6 +4065,7 @@ async function executeStep4(state) { return; } + throwIfStopped(); if (mail.provider === HOTMAIL_PROVIDER) { await addLog(`步骤 4:正在通过 ${mail.label} 轮询验证码...`); } else { @@ -4161,6 +4177,7 @@ async function runStep7Attempt(state) { await reuseOrCreateTab('signup-page', state.oauthUrl); } + throwIfStopped(); await addLog('步骤 7:正在准备认证页,必要时切换到一次性验证码登录...'); const prepareResult = await sendToContentScript('signup-page', { type: 'PREPARE_LOGIN_CODE', @@ -4178,6 +4195,7 @@ async function runStep7Attempt(state) { throw new Error(prepareResult.error); } + throwIfStopped(); if (mail.provider === HOTMAIL_PROVIDER) { await addLog(`步骤 7:正在通过 ${mail.label} 轮询验证码...`); } else { diff --git a/tests/verification-stop-propagation.test.js b/tests/verification-stop-propagation.test.js new file mode 100644 index 00000000..1ecbea13 --- /dev/null +++ b/tests/verification-stop-propagation.test.js @@ -0,0 +1,186 @@ +const assert = require('assert'); +const fs = require('fs'); + +const source = fs.readFileSync('background.js', 'utf8'); + +function extractFunction(name) { + const markers = [`async function ${name}(`, `function ${name}(`]; + const start = markers + .map(marker => source.indexOf(marker)) + .find(index => index >= 0); + if (start < 0) { + throw new Error(`missing function ${name}`); + } + + let parenDepth = 0; + let signatureEnded = false; + let braceStart = -1; + for (let i = start; i < source.length; i += 1) { + const ch = source[i]; + if (ch === '(') { + parenDepth += 1; + } else if (ch === ')') { + parenDepth -= 1; + if (parenDepth === 0) { + signatureEnded = true; + } + } else if (ch === '{' && signatureEnded) { + braceStart = i; + break; + } + } + if (braceStart < 0) { + throw new Error(`missing body for function ${name}`); + } + + let depth = 0; + let end = braceStart; + for (; end < source.length; end += 1) { + const ch = source[end]; + if (ch === '{') depth += 1; + if (ch === '}') { + depth -= 1; + if (depth === 0) { + end += 1; + break; + } + } + } + + return source.slice(start, end); +} + +async function testPollFreshVerificationCodeRethrowsStop() { + const bundle = [ + extractFunction('isStopError'), + extractFunction('throwIfStopped'), + extractFunction('pollFreshVerificationCode'), + ].join('\n'); + + const api = new Function(` +let stopRequested = false; +const STOP_ERROR_MESSAGE = '流程已被用户停止。'; +const HOTMAIL_PROVIDER = 'hotmail-api'; +const VERIFICATION_POLL_MAX_ROUNDS = 5; +const logs = []; +let resendCalls = 0; + +function getHotmailVerificationPollConfig() { + return {}; +} +async function pollHotmailVerificationCode() { + throw new Error('hotmail path should not run in this test'); +} +function getVerificationCodeStateKey(step) { + return step === 4 ? 'lastSignupCode' : 'lastLoginCode'; +} +function getVerificationPollPayload(step, state, overrides = {}) { + return { + filterAfterTimestamp: 123, + ...overrides, + }; +} +async function sendToMailContentScriptResilient() { + throw new Error(STOP_ERROR_MESSAGE); +} +async function requestVerificationCodeResend() { + resendCalls += 1; +} +async function addLog(message, level) { + logs.push({ message, level }); +} + +${bundle} + +return { + pollFreshVerificationCode, + snapshot() { + return { logs, resendCalls }; + }, +}; +`)(); + + let error = null; + try { + await api.pollFreshVerificationCode(7, {}, { provider: 'qq' }, {}); + } catch (err) { + error = err; + } + + const state = api.snapshot(); + assert.strictEqual(error?.message, '流程已被用户停止。', 'Stop 错误应原样向上抛出'); + assert.strictEqual(state.resendCalls, 0, 'Stop 后不应继续请求新的验证码'); + assert.deepStrictEqual(state.logs, [], 'Stop 后不应再记录普通失败或重试日志'); +} + +async function testResolveVerificationStepRethrowsStopFromFreshRequest() { + const bundle = [ + extractFunction('isStopError'), + extractFunction('resolveVerificationStep'), + ].join('\n'); + + const api = new Function(` +const STOP_ERROR_MESSAGE = '流程已被用户停止。'; +const HOTMAIL_PROVIDER = 'hotmail-api'; +const logs = []; +let pollCalls = 0; + +function getVerificationCodeStateKey(step) { + return step === 4 ? 'lastSignupCode' : 'lastLoginCode'; +} +function getHotmailVerificationPollConfig() { + return {}; +} +function getVerificationCodeLabel(step) { + return step === 4 ? '注册' : '登录'; +} +function isStep7RestartFromStep6Error() { + return false; +} +async function requestVerificationCodeResend() { + throw new Error(STOP_ERROR_MESSAGE); +} +async function addLog(message, level) { + logs.push({ message, level }); +} +async function pollFreshVerificationCode() { + pollCalls += 1; + return { code: '123456', emailTimestamp: Date.now() }; +} +async function submitVerificationCode() { + throw new Error('submit should not run in this test'); +} +async function setState() {} +async function completeStepFromBackground() {} + +${bundle} + +return { + resolveVerificationStep, + snapshot() { + return { logs, pollCalls }; + }, +}; +`)(); + + let error = null; + try { + await api.resolveVerificationStep(7, {}, { provider: 'qq' }, { requestFreshCodeFirst: true }); + } catch (err) { + error = err; + } + + const state = api.snapshot(); + assert.strictEqual(error?.message, '流程已被用户停止。', '首次请求新验证码收到 Stop 后应立即终止'); + assert.strictEqual(state.pollCalls, 0, 'Stop 后不应继续进入邮箱轮询'); + assert.deepStrictEqual(state.logs, [], 'Stop 后不应追加降级日志'); +} + +(async () => { + await testPollFreshVerificationCodeRethrowsStop(); + await testResolveVerificationStepRethrowsStopFromFreshRequest(); + console.log('verification stop propagation tests passed'); +})().catch((error) => { + console.error(error); + process.exit(1); +}); From 3d962f3210a1bcdeb6e20e66fe2cf08181ff8545 Mon Sep 17 00:00:00 2001 From: QLHazyCoder <2825305047@qq.com> Date: Mon, 13 Apr 2026 11:10:22 +0800 Subject: [PATCH 15/15] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=88=87=E6=8D=A2?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E6=AE=8B=E7=95=99=E6=97=A0=E5=85=B3=E9=85=8D?= =?UTF-8?q?=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- manifest.json | 28 +++++++++++++++++++++------ sidepanel/sidepanel.html | 2 +- sidepanel/sidepanel.js | 41 +++++++++++++++++++++++++++++++--------- 3 files changed, 55 insertions(+), 16 deletions(-) diff --git a/manifest.json b/manifest.json index 7d1898eb..cfc31ccd 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "多页面自动化", - "version": "6.1.0", + "version": "6.4.0", "description": "用于自动执行多步骤 OAuth 注册流程", "permissions": [ "sidePanel", @@ -29,7 +29,11 @@ "https://auth.openai.com/*", "https://accounts.openai.com/*" ], - "js": ["content/activation-utils.js", "content/utils.js", "content/signup-page.js"], + "js": [ + "content/activation-utils.js", + "content/utils.js", + "content/signup-page.js" + ], "run_at": "document_idle" }, { @@ -37,7 +41,11 @@ "https://mail.qq.com/*", "https://wx.mail.qq.com/*" ], - "js": ["content/activation-utils.js", "content/utils.js", "content/qq-mail.js"], + "js": [ + "content/activation-utils.js", + "content/utils.js", + "content/qq-mail.js" + ], "all_frames": true, "run_at": "document_idle" }, @@ -47,7 +55,11 @@ "https://*.mail.163.com/*", "https://webmail.vip.163.com/*" ], - "js": ["content/activation-utils.js", "content/utils.js", "content/mail-163.js"], + "js": [ + "content/activation-utils.js", + "content/utils.js", + "content/mail-163.js" + ], "all_frames": true, "run_at": "document_idle" }, @@ -55,7 +67,11 @@ "matches": [ "https://duckduckgo.com/email/settings/autofill*" ], - "js": ["content/activation-utils.js", "content/utils.js", "content/duck-mail.js"], + "js": [ + "content/activation-utils.js", + "content/utils.js", + "content/duck-mail.js" + ], "run_at": "document_idle" } ], @@ -71,4 +87,4 @@ "48": "icons/icon48.png", "128": "icons/icon128.png" } -} +} \ No newline at end of file diff --git a/sidepanel/sidepanel.html b/sidepanel/sidepanel.html index af607f49..06720646 100644 --- a/sidepanel/sidepanel.html +++ b/sidepanel/sidepanel.html @@ -137,7 +137,7 @@

多页面

-
+
邮箱生成