├── .github
├── FUNDING.yml
├── ISSUE_TEMPLATE
│ ├── bug_report.md
│ ├── config.yml
│ └── feature_request.md
├── pull_request_template.md
└── workflows
│ └── release.yml
├── .gitignore
├── .vscode-test.mjs
├── .vscode
├── extensions.json
├── launch.json
├── settings.json
└── tasks.json
├── .vscodeignore
├── CHANGELOG.md
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── assets
├── demo.gif
├── icon.png
└── icon.svg
├── esbuild.js
├── eslint.config.js
├── package-lock.json
├── package.json
├── src
├── browser
│ ├── commands
│ │ └── index.ts
│ ├── events
│ │ └── event-handlers.ts
│ ├── monitor.ts
│ ├── services
│ │ ├── health-checker.ts
│ │ ├── page-manager.ts
│ │ └── status-bar.ts
│ └── utils
│ │ └── log-handler.ts
├── commands
│ └── index.ts
├── extension.ts
├── ios
│ ├── commands
│ │ └── index.ts
│ ├── services
│ │ ├── simulator-scanner.ts
│ │ └── status-bar.ts
│ └── simulator.ts
├── shared
│ ├── composer
│ │ └── integration.ts
│ ├── config
│ │ ├── feature-toggles.ts
│ │ ├── index.ts
│ │ └── log-filters.ts
│ ├── types
│ │ └── index.ts
│ └── utils
│ │ ├── clipboard.ts
│ │ ├── error-handler.ts
│ │ ├── keybinding-manager.ts
│ │ ├── settings.ts
│ │ └── toast.ts
└── views
│ ├── settings-panel.ts
│ └── templates
│ └── settings-panel.template.ts
└── tsconfig.json
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | # These are supported funding model platforms
2 |
3 | github: [saketsarin]
4 | patreon: saketsarin
5 | open_collective: # Replace with a single Open Collective username
6 | ko_fi: # Replace with a single Ko-fi username
7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
9 | liberapay: # Replace with a single Liberapay username
10 | issuehunt: # Replace with a single IssueHunt username
11 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
12 | polar: # Replace with a single Polar username
13 | buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
14 | thanks_dev: # Replace with a single thanks.dev username
15 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
16 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve
4 | title: "[BUG] "
5 | labels: bug
6 | assignees: ""
7 | ---
8 |
9 | **Describe the bug**
10 | A clear and concise description of what the bug is.
11 |
12 | **To Reproduce**
13 | Steps to reproduce the behavior:
14 |
15 | 1. Go to '...'
16 | 2. Click on '....'
17 | 3. Scroll down to '....'
18 | 4. See error
19 |
20 | **Expected behavior**
21 | A clear and concise description of what you expected to happen.
22 |
23 | **Screenshots**
24 | If applicable, add screenshots to help explain your problem.
25 |
26 | **Environment (please complete the following information):**
27 |
28 | - OS: [e.g. macOS 13.1]
29 | - Chrome/Chromium Version: [e.g. 120.0.6099.129]
30 | - Cursor Version: [e.g. 0.8.0]
31 | - Node.js Version: [e.g. 18.0.0]
32 |
33 | **Additional context**
34 | Add any other context about the problem here.
35 |
36 | **Console Output**
37 |
38 | ```
39 | If you have any relevant console output, paste it here.
40 | ```
41 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/config.yml:
--------------------------------------------------------------------------------
1 | blank_issues_enabled: false
2 | contact_links:
3 | - name: Questions & Discussions
4 | url: https://github.com/saketsarin/composer-web/discussions
5 | about: Please ask and answer questions here.
6 | - name: Documentation
7 | url: https://github.com/saketsarin/composer-web/blob/main/README.md
8 | about: Check our documentation for guides and common solutions.
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest an idea for this project
4 | title: "[FEATURE] "
5 | labels: enhancement
6 | assignees: ""
7 | ---
8 |
9 | **Is your feature request related to a problem? Please describe.**
10 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
11 |
12 | **Describe the solution you'd like**
13 | A clear and concise description of what you want to happen.
14 |
15 | **Describe alternatives you've considered**
16 | A clear and concise description of any alternative solutions or features you've considered.
17 |
18 | **Additional context**
19 | Add any other context or screenshots about the feature request here.
20 |
21 | **Potential Implementation**
22 | If you have any ideas about how this could be implemented, share them here.
23 |
--------------------------------------------------------------------------------
/.github/pull_request_template.md:
--------------------------------------------------------------------------------
1 | ## Description
2 |
3 | Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context.
4 |
5 | Fixes # (issue)
6 |
7 | ## Type of change
8 |
9 | Please delete options that are not relevant.
10 |
11 | - [ ] Bug fix (non-breaking change which fixes an issue)
12 | - [ ] New feature (non-breaking change which adds functionality)
13 | - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
14 | - [ ] Documentation update
15 |
16 | ## How Has This Been Tested?
17 |
18 | Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce.
19 |
20 | - [ ] Test A
21 | - [ ] Test B
22 |
23 | ## Checklist:
24 |
25 | - [ ] My code follows the style guidelines of this project
26 | - [ ] I have performed a self-review of my own code
27 | - [ ] I have commented my code, particularly in hard-to-understand areas
28 | - [ ] I have made corresponding changes to the documentation
29 | - [ ] My changes generate no new warnings
30 | - [ ] I have added tests that prove my fix is effective or that my feature works
31 | - [ ] New and existing unit tests pass locally with my changes
32 | - [ ] Any dependent changes have been merged and published in downstream modules
33 |
34 | ## Screenshots (if appropriate):
35 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release Extension
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | tags:
8 | - 'v*'
9 |
10 | jobs:
11 | release:
12 | runs-on: macos-latest
13 | permissions:
14 | contents: write
15 | steps:
16 | - uses: actions/checkout@v4
17 |
18 | - name: Setup Node.js
19 | uses: actions/setup-node@v4
20 | with:
21 | node-version: '18'
22 | cache: 'npm'
23 |
24 | - name: Install dependencies
25 | run: npm ci
26 |
27 | - name: Package Extension
28 | run: npm run package
29 |
30 | - name: Get Package Version
31 | id: get_version
32 | run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
33 |
34 | - name: Create Release
35 | uses: softprops/action-gh-release@v1
36 | if: startsWith(github.ref, 'refs/tags/')
37 | with:
38 | files: composer-web-*.vsix
39 | name: v${{ steps.get_version.outputs.version }}
40 | generate_release_notes: true
41 | draft: false
42 | prerelease: false
43 | env:
44 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
45 |
46 | - name: Create Release (from main)
47 | uses: softprops/action-gh-release@v1
48 | if: github.ref == 'refs/heads/main'
49 | with:
50 | files: composer-web-*.vsix
51 | name: v${{ steps.get_version.outputs.version }}
52 | tag_name: v${{ steps.get_version.outputs.version }}
53 | generate_release_notes: true
54 | draft: false
55 | prerelease: false
56 | env:
57 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | out
2 | dist
3 | node_modules
4 | .vscode-test/
5 | .DS_Store
6 | coverage
7 | .nyc_output
8 | *.log
9 | .env
10 | .env.*
11 | !.env.example
12 | tmp/
13 | .idea/
14 | *.iml
15 | .history/
16 | .temp/
17 | .cache/
18 | *.tsbuildinfo
19 | test-workspace/
20 | .cursorrules
--------------------------------------------------------------------------------
/.vscode-test.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('@vscode/test-cli').TestConfiguration} */
2 | export default {
3 | files: "out/test/**/*.test.js",
4 | workspaceFolder: "test-workspace",
5 | mocha: {
6 | ui: "tdd",
7 | timeout: 20000,
8 | },
9 | version: "stable",
10 | };
11 |
--------------------------------------------------------------------------------
/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | // See http://go.microsoft.com/fwlink/?LinkId=827846
3 | // for the documentation about the extensions.json format
4 | "recommendations": ["dbaeumer.vscode-eslint", "connor4312.esbuild-problem-matchers", "ms-vscode.extension-test-runner"]
5 | }
6 |
--------------------------------------------------------------------------------
/.vscode/launch.json:
--------------------------------------------------------------------------------
1 | // A launch configuration that compiles the extension and then opens it inside a new window
2 | // Use IntelliSense to learn about possible attributes.
3 | // Hover to view descriptions of existing attributes.
4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
5 | {
6 | "version": "0.2.0",
7 | "configurations": [
8 | {
9 | "name": "Run Extension",
10 | "type": "extensionHost",
11 | "request": "launch",
12 | "args": [
13 | "--extensionDevelopmentPath=${workspaceFolder}",
14 | "${workspaceFolder}/test-workspace"
15 | ],
16 | "outFiles": ["${workspaceFolder}/dist/**/*.js"],
17 | "preLaunchTask": "npm: watch"
18 | }
19 | ]
20 | }
21 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | // Place your settings in this file to overwrite default and user settings.
2 | {
3 | "files.exclude": {
4 | "out": false, // set this to true to hide the "out" folder with the compiled JS files
5 | "dist": false // set this to true to hide the "dist" folder with the compiled JS files
6 | },
7 | "search.exclude": {
8 | "out": true, // set this to false to include "out" folder in search results
9 | "dist": true // set this to false to include "dist" folder in search results
10 | },
11 | // Turn off tsc task auto detection since we have the necessary tasks as npm scripts
12 | "typescript.tsc.autoDetect": "off",
13 | "yaml.validate": true,
14 | "yaml.customTags": [],
15 | "yaml.schemas": {
16 | "https://json.schemastore.org/github-issue-config.json": ".github/ISSUE_TEMPLATE/config.yml"
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/.vscode/tasks.json:
--------------------------------------------------------------------------------
1 | // See https://go.microsoft.com/fwlink/?LinkId=733558
2 | // for the documentation about the tasks.json format
3 | {
4 | "version": "2.0.0",
5 | "tasks": [
6 | {
7 | "type": "npm",
8 | "script": "watch",
9 | "problemMatcher": {
10 | "owner": "typescript",
11 | "pattern": {
12 | "regexp": "^([^\\s].*)\\((\\d+,\\d+)\\):\\s+(error|warning|info)\\s+(.*)$",
13 | "file": 1,
14 | "location": 2,
15 | "severity": 3,
16 | "message": 4
17 | },
18 | "background": {
19 | "activeOnStart": true,
20 | "beginsPattern": "\\[watch\\] build started",
21 | "endsPattern": "\\[watch\\] build finished"
22 | }
23 | },
24 | "isBackground": true,
25 | "presentation": {
26 | "reveal": "never"
27 | },
28 | "group": {
29 | "kind": "build",
30 | "isDefault": true
31 | }
32 | },
33 | {
34 | "type": "npm",
35 | "script": "watch:esbuild",
36 | "group": "build",
37 | "problemMatcher": "$esbuild-watch",
38 | "isBackground": true,
39 | "label": "npm: watch:esbuild",
40 | "presentation": {
41 | "group": "watch",
42 | "reveal": "never"
43 | }
44 | },
45 | {
46 | "type": "npm",
47 | "script": "watch:tsc",
48 | "group": "build",
49 | "problemMatcher": "$tsc-watch",
50 | "isBackground": true,
51 | "label": "npm: watch:tsc",
52 | "presentation": {
53 | "group": "watch",
54 | "reveal": "never"
55 | }
56 | },
57 | {
58 | "type": "npm",
59 | "script": "watch-tests",
60 | "problemMatcher": "$tsc-watch",
61 | "isBackground": true,
62 | "presentation": {
63 | "reveal": "never",
64 | "group": "watchers"
65 | },
66 | "group": "build"
67 | },
68 | {
69 | "label": "tasks: watch-tests",
70 | "dependsOn": ["npm: watch", "npm: watch-tests"],
71 | "problemMatcher": []
72 | }
73 | ]
74 | }
75 |
--------------------------------------------------------------------------------
/.vscodeignore:
--------------------------------------------------------------------------------
1 | .git
2 | .github
3 | .gitignore
4 | .vscode/**
5 | .vscode-test/**
6 | out/**
7 | node_modules/**
8 | src/**
9 | .eslintrc.json
10 | .eslintignore
11 | tsconfig.json
12 | webpack.config.js
13 | esbuild.js
14 | **/*.map
15 | **/*.ts
16 | !dist/**/*.d.ts
17 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | All notable changes to this project will be documented in this file.
4 |
5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7 |
8 | ## [1.0.8] - 2025-03-26
9 |
10 | ### Added
11 |
12 | - iOS Simulator Integration (Beta): Capture screenshots from iOS simulators
13 | - Beta Feature Toggle: New settings panel toggle to enable/disable beta features
14 | - Enhanced iOS device monitoring with screenshot capture capabilities
15 |
16 | ## [1.0.7] - 2025-03-20
17 |
18 | ### Added
19 |
20 | - Log Filtering Functionality: Implemented filtering capabilities in the SettingsPanel and BrowserMonitor
21 | - Enhanced log management with customizable filtering options
22 |
23 | ## [1.0.6] - 2025-03-18
24 |
25 | ### Fixed
26 |
27 | - Issues with opening Chat Window: With the latest Cursor update, there were issues in opening the chat window when sending the logs. It has been fixed now
28 |
29 | ## [1.0.5] - 2025-03-18
30 |
31 | ### Added
32 |
33 | - Keybinding Management: Implemented a new keybinding management system with a customizable settings panel.
34 | - Keybinding Panel: Added a new `KeybindingPanel` for managing keybindings directly within the extension.
35 |
36 | ### Changed
37 |
38 | - Settings Panel: Moved the settings panel to the sidebar for improved accessibility.
39 |
40 | ### Fixed
41 |
42 | - Health Check: Enhanced health check by actively monitoring the status of tabs.
43 | - Console Log Formatting: Improved formatting for console logs to enhance readability.
44 |
45 | ## [1.0.4] - 2025-02-20
46 |
47 | ### Added
48 |
49 | - Code of Conduct for community guidelines
50 | - Comprehensive CONTRIBUTING.md guidelines
51 | - New ToastService for centralized notification management
52 | - Enhanced session stability and connection handling
53 |
54 | ### Changed
55 |
56 | - Improved error handling and retry logic in composer integration
57 | - Simplified Windows clipboard operations using VSCode API
58 | - Updated documentation for clarity and completeness
59 | - Improved project structure
60 |
61 | ### Fixed
62 |
63 | - Windows clipboard text handling and clearing methods
64 | - Enhanced browser session stability
65 | - Improved connection handling and recovery
66 |
67 | ## [1.0.3] - 2025-02-18
68 |
69 | ### Added
70 |
71 | - Automatic session health monitoring
72 | - Proactive detection of stale browser sessions
73 | - Improved reconnection handling
74 |
75 | ### Changed
76 |
77 | - Enhanced error messages for connection issues
78 | - Better handling of browser disconnection events
79 | - Improved status bar state management
80 |
81 | ### Fixed
82 |
83 | - Issue with stale sessions requiring Cursor restart
84 | - Status bar showing connected state for inactive sessions
85 |
86 | ## [1.0.2] - 2025-02-17
87 |
88 | - Fixed minor bugs and issues
89 | - Improved stability
90 |
91 | ## [1.0.1] - 2025-02-16
92 |
93 | ### Added
94 |
95 | - Additional command palette actions:
96 | - Clear logs (with confirmation)
97 | - Send logs only
98 | - Send screenshot only
99 | - Improved status bar indicators with connection state
100 | - Additional keyboard shortcuts for power users:
101 | - `Cmd/Ctrl + Shift + ;` - Clear logs
102 | - `Cmd/Ctrl + '` - Send logs only
103 | - `Cmd/Ctrl + Shift + '` - Send screenshot only
104 |
105 | ### Changed
106 |
107 | - Improved progress notifications for each action
108 | - Enhanced error handling with specific messages
109 | - Renamed commands for better clarity
110 |
111 | ## [1.0.0] - 2025-02-14
112 |
113 | ### Added
114 |
115 | - Initial release
116 | - Full page screenshot capture
117 | - Real-time console log monitoring
118 | - Network request tracking
119 | - Smart capture functionality
120 | - Multi-tab support
121 | - Chrome DevTools Protocol integration
122 | - Keyboard shortcuts for quick capture
123 | - Progress notifications during capture
124 | - Error handling and recovery
125 | - Documentation and setup guides
126 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Contributor Covenant Code of Conduct
2 |
3 | ## Our Pledge
4 |
5 | We as members, contributors, and leaders pledge to make participation in our
6 | community a harassment-free experience for everyone, regardless of age, body
7 | size, visible or invisible disability, ethnicity, sex characteristics, gender
8 | identity and expression, level of experience, education, socio-economic status,
9 | nationality, personal appearance, race, religion, or sexual identity
10 | and orientation.
11 |
12 | We pledge to act and interact in ways that contribute to an open, welcoming,
13 | diverse, inclusive, and healthy community.
14 |
15 | ## Our Standards
16 |
17 | Examples of behavior that contributes to a positive environment for our
18 | community include:
19 |
20 | - Demonstrating empathy and kindness toward other people
21 | - Being respectful of differing opinions, viewpoints, and experiences
22 | - Giving and gracefully accepting constructive feedback
23 | - Accepting responsibility and apologizing to those affected by our mistakes,
24 | and learning from the experience
25 | - Focusing on what is best not just for us as individuals, but for the
26 | overall community
27 |
28 | Examples of unacceptable behavior include:
29 |
30 | - The use of sexualized language or imagery, and sexual attention or
31 | advances of any kind
32 | - Trolling, insulting or derogatory comments, and personal or political attacks
33 | - Public or private harassment
34 | - Publishing others' private information, such as a physical or email
35 | address, without their explicit permission
36 | - Other conduct which could reasonably be considered inappropriate in a
37 | professional setting
38 |
39 | ## Enforcement Responsibilities
40 |
41 | Community leaders are responsible for clarifying and enforcing our standards of
42 | acceptable behavior and will take appropriate and fair corrective action in
43 | response to any behavior that they deem inappropriate, threatening, offensive,
44 | or harmful.
45 |
46 | ## Scope
47 |
48 | This Code of Conduct applies within all community spaces, and also applies when
49 | an individual is officially representing the community in public spaces.
50 |
51 | ## Enforcement
52 |
53 | Instances of abusive, harassing, or otherwise unacceptable behavior may be
54 | reported to the community leaders responsible for enforcement.
55 | All complaints will be reviewed and investigated promptly and fairly.
56 |
57 | ## Attribution
58 |
59 | This Code of Conduct is adapted from the [Contributor Covenant][homepage],
60 | version 2.0, available at
61 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
62 |
63 | [homepage]: https://www.contributor-covenant.org
64 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing to Composer Web Extension
2 |
3 | First off, thank you for considering contributing to Composer Web Extension! It's people like you that make this extension such a great tool.
4 |
5 | ## Code of Conduct
6 |
7 | This project and everyone participating in it is governed by our [Code of Conduct](CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code.
8 |
9 | ## How Can I Contribute?
10 |
11 | ### Reporting Bugs
12 |
13 | Before creating bug reports, please check the existing issues as you might find out that you don't need to create one. When you are creating a bug report, please include as many details as possible:
14 |
15 | - Use a clear and descriptive title
16 | - Describe the exact steps which reproduce the problem
17 | - Provide specific examples to demonstrate the steps
18 | - Describe the behavior you observed after following the steps
19 | - Explain which behavior you expected to see instead and why
20 | - Include screenshots if possible
21 | - Include your environment details:
22 | - OS version
23 | - Chrome/Chromium version
24 | - Cursor version
25 | - Node.js version
26 |
27 | ### Suggesting Enhancements
28 |
29 | Enhancement suggestions are tracked as GitHub issues. When creating an enhancement suggestion, please include:
30 |
31 | - A clear and descriptive title
32 | - A detailed description of the proposed functionality
33 | - Explain why this enhancement would be useful
34 | - List any similar features in other extensions if you know of any
35 | - Include mockups or examples if applicable
36 |
37 | ### Pull Requests
38 |
39 | - Fill in the required template
40 | - Do not include issue numbers in the PR title
41 | - Include screenshots and animated GIFs in your pull request whenever possible
42 | - Follow our coding conventions and style guide
43 | - Document new code
44 | - End all files with a newline
45 |
46 | ## Development Process
47 |
48 | 1. Fork the repo and create your branch from `main`
49 | 2. Run `npm install` to install dependencies
50 | 3. Make your changes
51 | 4. Add tests if applicable
52 | 5. Run `npm test` to ensure nothing is broken
53 | 6. Update documentation if needed
54 | 7. Create your pull request
55 |
56 | ### Local Development Setup
57 |
58 | ```bash
59 | # Clone your fork
60 | git clone https://github.com/saketsarin/composer-web.git
61 |
62 | # Install dependencies
63 | npm install
64 |
65 | # Start development
66 | npm run watch
67 |
68 | # Run tests
69 | npm test
70 | ```
71 |
72 | ### Coding Style
73 |
74 | - Use 2 spaces for indentation
75 | - Use semicolons
76 | - Follow TypeScript best practices
77 | - Write meaningful commit messages following [Conventional Commits](https://www.conventionalcommits.org/)
78 |
79 | ## Community
80 |
81 | - Join our discussions in GitHub issues
82 | - Follow our updates
83 |
84 | ## Questions?
85 |
86 | Feel free to open an issue with your question or reach out to the maintainers.
87 |
88 | Thank you for contributing! 🎉
89 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 Saket Sarin
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | Composer Web Extension
4 |
5 |
6 | 
7 |
8 | A powerful Cursor extension that captures live browser content and logs directly into Composer. Perfect for debugging, documentation, and sharing web content with context.
9 |
10 | ## Features
11 |
12 | - 📸 **Smart Capture**: One shortcut to connect and capture everything
13 | - 📊 **Real-time Monitoring**: Console logs and network requests
14 | - 🔍 **Log Filtering**: Customizable filtering for console logs
15 | - 🎯 **Multi-tab Support**: Select from any open tab in your debugging browser
16 | - 📱 **iOS Simulator Integration** (Beta): Capture iOS simulator screenshots
17 | - ⚡ **Advanced Options**: Additional commands for specific capture needs
18 | - 🎛️ **Keybinding Management**: Customize keybindings directly from the new settings panel.
19 |
20 | ## How It Works
21 |
22 | 1. **Connect to a Tab**:
23 |
24 | - Press `Cmd/Ctrl + ;` or click the connect button in the status bar
25 | - Select your target tab from the list
26 | - The extension starts monitoring console logs and network requests
27 |
28 | 2. **Monitor Activity**:
29 |
30 | - The status bar shows which tab is being monitored
31 | - All console logs and network requests are collected in real-time
32 | - Logs persist until you clear them or disconnect
33 |
34 | 3. **Capture State**:
35 |
36 | - Press `Cmd/Ctrl + ;` again or click the capture button
37 | - The extension captures everything:
38 | - A full-page screenshot
39 | - All console logs since connection
40 | - All network requests since connection
41 | - Everything is sent directly to Composer
42 |
43 | 4. **iOS Simulator Integration** (Beta):
44 | - Enable in Settings Panel under "Beta Features"
45 | - Connect to a running iOS simulator
46 | - Capture screenshots from iOS apps
47 | - Send directly to Composer for AI assistance
48 |
49 | ## Quick Start
50 |
51 | 1. Launch Chrome with remote debugging:
52 |
53 | ```bash
54 | # macOS
55 | open -n -a "Google Chrome" --args --remote-debugging-port=9222 --user-data-dir=/tmp/chrome-debug-profile
56 |
57 | # Windows
58 | "C:\Program Files\Google\Chrome\Application\chrome.exe" --remote-debugging-port=9222 --user-data-dir=%TEMP%\chrome-debug-profile
59 |
60 | # Linux
61 | google-chrome --remote-debugging-port=9222 --user-data-dir=/tmp/chrome-debug-profile
62 | ```
63 |
64 | 2. Press `Cmd/Ctrl + ;` to connect and capture!
65 |
66 | ## Additional Features
67 |
68 | Available through Command Palette (`Cmd/Ctrl + Shift + P`) or keyboard shortcuts:
69 |
70 | - Clear logs: `Cmd/Ctrl + Shift + ;`
71 | - Send only logs: `Cmd/Ctrl + '`
72 | - Send only screenshot: `Cmd/Ctrl + Shift + '`
73 | - iOS Simulator: Available in command palette when enabled
74 |
75 | ## Usage Tips
76 |
77 | 1. **Status Bar Indicator**:
78 |
79 | - 🔌 Not Connected: Click to connect to a tab
80 | - 👁️ Connected: Click to capture current tab state
81 | - 📱 iOS Simulator: Shows connected simulator status
82 |
83 | 2. **Best Practices**:
84 | - Wait for page to load completely
85 | - Clear logs when starting new session
86 | - Use fresh Chrome profile for best results
87 | - Use specific commands when you need just logs or screenshots
88 | - **Keybinding Panel**: Access the keybinding panel via the Command Palette to manage and customize keybindings.
89 | - **Beta Features**: Enable beta features like iOS simulator integration in the Settings Panel
90 |
91 | ## Troubleshooting
92 |
93 | 1. **Can't Connect?**
94 |
95 | - Ensure Chrome is running with debugging flag
96 | - Check if port 9222 is available
97 | - Try restarting Chrome
98 |
99 | 2. **Session Disconnected?**
100 |
101 | - The extension will automatically detect stale sessions and show a notification
102 | - Click the status bar item to reconnect
103 | - No need to restart Cursor - just reconnect when prompted
104 |
105 | 3. **Incomplete Capture?**
106 | - Wait for all content to load
107 | - Scroll through the page once
108 | - Check console for errors
109 |
110 | ## Requirements
111 |
112 | - Cursor (latest version)
113 | - Google Chrome/Chromium
114 | - Node.js ≥ 18.0.0
115 |
116 | ## Contributing
117 |
118 | We welcome contributions from the community! Here's how you can help:
119 |
120 | ### 🐛 Found a Bug?
121 |
122 | - **Ensure the bug hasn't already been reported** by searching our [Issues](../../issues)
123 | - If you can't find an existing issue, [open a new bug report](../../issues/new?template=bug_report.md) using our bug report template
124 |
125 | ### 💡 Have a Feature Idea?
126 |
127 | - Check our [Issues](../../issues) to see if it's already suggested
128 | - If not, [create a feature request](../../issues/new?template=feature_request.md) using our feature request template
129 |
130 | ### 🛠️ Want to Contribute Code?
131 |
132 | 1. Read our [Contributing Guide](CONTRIBUTING.md)
133 | 2. Fork the repository
134 | 3. Create your feature branch
135 | 4. Make your changes
136 | 5. Open a [Pull Request](../../pulls) using our PR template
137 |
138 | For more details, check out our:
139 |
140 | - [Code of Conduct](CODE_OF_CONDUCT.md)
141 | - [Contributing Guidelines](CONTRIBUTING.md)
142 |
143 | ## License
144 |
145 | MIT - See LICENSE file for details
146 |
--------------------------------------------------------------------------------
/assets/demo.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/saketsarin/composer-web/2a3b19e9a062bce387e2b99970f2ed0768543d7e/assets/demo.gif
--------------------------------------------------------------------------------
/assets/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/saketsarin/composer-web/2a3b19e9a062bce387e2b99970f2ed0768543d7e/assets/icon.png
--------------------------------------------------------------------------------
/assets/icon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/esbuild.js:
--------------------------------------------------------------------------------
1 | const esbuild = require("esbuild");
2 |
3 | const production = process.argv.includes('--production');
4 | const watch = process.argv.includes('--watch');
5 |
6 | /**
7 | * @type {import('esbuild').Plugin}
8 | */
9 | const esbuildProblemMatcherPlugin = {
10 | name: 'esbuild-problem-matcher',
11 |
12 | setup(build) {
13 | build.onStart(() => {
14 | console.log('[watch] build started');
15 | });
16 | build.onEnd((result) => {
17 | result.errors.forEach(({ text, location }) => {
18 | console.error(`✘ [ERROR] ${text}`);
19 | console.error(` ${location.file}:${location.line}:${location.column}:`);
20 | });
21 | console.log('[watch] build finished');
22 | });
23 | },
24 | };
25 |
26 | async function main() {
27 | const ctx = await esbuild.context({
28 | entryPoints: [
29 | 'src/extension.ts'
30 | ],
31 | bundle: true,
32 | format: 'cjs',
33 | minify: production,
34 | sourcemap: !production,
35 | sourcesContent: false,
36 | platform: 'node',
37 | outfile: 'dist/extension.js',
38 | external: ['vscode'],
39 | logLevel: 'silent',
40 | plugins: [
41 | /* add to the end of plugins array */
42 | esbuildProblemMatcherPlugin,
43 | ],
44 | });
45 | if (watch) {
46 | await ctx.watch();
47 | } else {
48 | await ctx.rebuild();
49 | await ctx.dispose();
50 | }
51 | }
52 |
53 | main().catch(e => {
54 | console.error(e);
55 | process.exit(1);
56 | });
57 |
--------------------------------------------------------------------------------
/eslint.config.js:
--------------------------------------------------------------------------------
1 | module.exports = [
2 | {
3 | files: ["**/*.{js,ts}"],
4 | ignores: ["eslint.config.js"],
5 | languageOptions: {
6 | ecmaVersion: "latest",
7 | sourceType: "module",
8 | parser: require("@typescript-eslint/parser"),
9 | parserOptions: {
10 | project: "./tsconfig.json",
11 | },
12 | },
13 | plugins: {
14 | "@typescript-eslint": require("@typescript-eslint/eslint-plugin"),
15 | },
16 | rules: {
17 | semi: ["error", "always"],
18 | quotes: ["error", "double"],
19 | "no-unused-vars": "warn",
20 | },
21 | },
22 | ];
23 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "composer-web",
3 | "displayName": "Composer Web",
4 | "description": "A powerful browser integration extension for Cursor that captures live browser content and logs directly into Composer",
5 | "version": "1.0.8",
6 | "publisher": "saketsarin",
7 | "icon": "assets/icon.png",
8 | "private": false,
9 | "repository": {
10 | "type": "git",
11 | "url": "https://github.com/saketsarin/composer-web.git"
12 | },
13 | "bugs": {
14 | "url": "https://github.com/saketsarin/composer-web/issues"
15 | },
16 | "homepage": "https://github.com/saketsarin/composer-web#readme",
17 | "keywords": [
18 | "cursor",
19 | "browser",
20 | "debugging",
21 | "screenshot",
22 | "console",
23 | "network",
24 | "monitoring",
25 | "chrome",
26 | "devtools",
27 | "ios",
28 | "simulator"
29 | ],
30 | "engines": {
31 | "vscode": "^1.96.0",
32 | "node": ">=18.0.0"
33 | },
34 | "categories": [
35 | "Other"
36 | ],
37 | "activationEvents": [
38 | "onStartupFinished"
39 | ],
40 | "main": "./dist/extension.js",
41 | "contributes": {
42 | "configuration": {
43 | "title": "Composer Web",
44 | "properties": {
45 | "composerWeb.captureFormat": {
46 | "type": "string",
47 | "default": "png",
48 | "enum": [
49 | "png",
50 | "jpg"
51 | ],
52 | "description": "Format for UI captures"
53 | },
54 | "composerWeb.includeStyles": {
55 | "type": "boolean",
56 | "default": true,
57 | "description": "Include CSS styles in UI data"
58 | },
59 | "composerWeb.quality": {
60 | "type": "number",
61 | "default": 0.95,
62 | "minimum": 0.1,
63 | "maximum": 1,
64 | "description": "Quality of the captured images (0.1-1.0)"
65 | },
66 | "composerWeb.remoteDebuggingUrl": {
67 | "type": "string",
68 | "default": "http://localhost:9222",
69 | "description": "The remote debugging URL for your browser (e.g., http://localhost:9222). Ensure your browser is started with the --remote-debugging-port flag."
70 | },
71 | "composerWeb.customKeybindings": {
72 | "type": "array",
73 | "default": [],
74 | "description": "Custom keybindings for Composer Web commands",
75 | "items": {
76 | "type": "object",
77 | "properties": {
78 | "command": {
79 | "type": "string",
80 | "description": "Command ID"
81 | },
82 | "key": {
83 | "type": "string",
84 | "description": "Keybinding for Windows/Linux"
85 | },
86 | "mac": {
87 | "type": "string",
88 | "description": "Keybinding for macOS"
89 | }
90 | }
91 | }
92 | }
93 | }
94 | },
95 | "commands": [
96 | {
97 | "command": "web-preview.smartCapture",
98 | "title": "Capture/Connect Browser Tab",
99 | "category": "Composer Web"
100 | },
101 | {
102 | "command": "web-preview.clearLogs",
103 | "title": "Clear Browser Logs",
104 | "category": "Composer Web"
105 | },
106 | {
107 | "command": "web-preview.sendLogs",
108 | "title": "Send Tab Logs to Composer",
109 | "category": "Composer Web"
110 | },
111 | {
112 | "command": "web-preview.sendScreenshot",
113 | "title": "Send Tab Screenshot to Composer",
114 | "category": "Composer Web"
115 | },
116 | {
117 | "command": "web-preview.openSettings",
118 | "title": "Open Settings",
119 | "category": "Composer Web"
120 | },
121 | {
122 | "command": "web-preview.connectiOSSimulator",
123 | "title": "Connect iOS Simulator",
124 | "category": "Composer Web"
125 | },
126 | {
127 | "command": "web-preview.sendiOSScreenshot",
128 | "title": "Send iOS Screenshot to Composer",
129 | "category": "Composer Web"
130 | },
131 | {
132 | "command": "web-preview.captureiOS",
133 | "title": "Capture iOS Simulator Screenshot",
134 | "category": "Composer Web"
135 | }
136 | ],
137 | "keybindings": [
138 | {
139 | "command": "web-preview.smartCapture",
140 | "key": "ctrl+;",
141 | "mac": "cmd+;"
142 | },
143 | {
144 | "command": "web-preview.clearLogs",
145 | "key": "ctrl+shift+;",
146 | "mac": "cmd+shift+;"
147 | },
148 | {
149 | "command": "web-preview.sendLogs",
150 | "key": "ctrl+'",
151 | "mac": "cmd+'"
152 | },
153 | {
154 | "command": "web-preview.sendScreenshot",
155 | "key": "ctrl+shift+'",
156 | "mac": "cmd+shift+'"
157 | }
158 | ],
159 | "menus": {
160 | "commandPalette": [
161 | {
162 | "command": "web-preview.clearLogs",
163 | "when": "web-preview:isConnected"
164 | },
165 | {
166 | "command": "web-preview.sendLogs",
167 | "when": "web-preview:isConnected"
168 | },
169 | {
170 | "command": "web-preview.sendScreenshot",
171 | "when": "web-preview:isConnected"
172 | },
173 | {
174 | "command": "web-preview.connectiOSSimulator",
175 | "when": "web-preview:iOSFeaturesEnabled"
176 | },
177 | {
178 | "command": "web-preview.sendiOSScreenshot",
179 | "when": "web-preview:iOSConnected && web-preview:iOSFeaturesEnabled"
180 | },
181 | {
182 | "command": "web-preview.captureiOS",
183 | "when": "web-preview:iOSConnected && web-preview:iOSFeaturesEnabled"
184 | }
185 | ],
186 | "editor/context": [
187 | {
188 | "submenu": "web-preview.submenu",
189 | "group": "web-preview"
190 | }
191 | ],
192 | "web-preview.submenu": [
193 | {
194 | "command": "web-preview.clearLogs",
195 | "group": "1_logs"
196 | },
197 | {
198 | "command": "web-preview.sendLogs",
199 | "group": "1_logs"
200 | },
201 | {
202 | "command": "web-preview.sendScreenshot",
203 | "group": "2_capture"
204 | },
205 | {
206 | "command": "web-preview.smartCapture",
207 | "group": "2_capture"
208 | },
209 | {
210 | "command": "web-preview.connectiOSSimulator",
211 | "group": "3_ios",
212 | "when": "web-preview:iOSFeaturesEnabled"
213 | },
214 | {
215 | "command": "web-preview.sendiOSScreenshot",
216 | "group": "3_ios",
217 | "when": "web-preview:iOSConnected && web-preview:iOSFeaturesEnabled"
218 | },
219 | {
220 | "command": "web-preview.captureiOS",
221 | "group": "3_ios",
222 | "when": "web-preview:iOSConnected && web-preview:iOSFeaturesEnabled"
223 | },
224 | {
225 | "command": "web-preview.openSettings",
226 | "group": "4_config"
227 | }
228 | ]
229 | },
230 | "submenus": [
231 | {
232 | "id": "web-preview.submenu",
233 | "label": "Composer Web"
234 | }
235 | ],
236 | "viewsContainers": {
237 | "activitybar": [
238 | {
239 | "id": "composer-web",
240 | "title": "Composer Web",
241 | "icon": "assets/icon.svg"
242 | }
243 | ]
244 | },
245 | "views": {
246 | "composer-web": [
247 | {
248 | "id": "composer-web.settings",
249 | "name": "Composer Web - Settings",
250 | "type": "webview"
251 | }
252 | ]
253 | }
254 | },
255 | "scripts": {
256 | "vscode:prepublish": "npm run compile",
257 | "compile": "npm run check-types && npm run lint && node esbuild.js",
258 | "watch": "npm-run-all -p watch:*",
259 | "watch:esbuild": "node esbuild.js --watch",
260 | "watch:tsc": "tsc --noEmit --watch --project tsconfig.json",
261 | "package": "vsce package",
262 | "compile-tests": "tsc -p . --outDir out",
263 | "watch-tests": "tsc -p . -w --outDir out",
264 | "pretest": "npm run compile && npm run compile-tests",
265 | "test": "node ./out/test/runTest.js",
266 | "check-types": "tsc --noEmit",
267 | "lint": "eslint src"
268 | },
269 | "devDependencies": {
270 | "@types/mocha": "^9.1.1",
271 | "@types/node": "^16.18.0",
272 | "@types/vscode": "^1.96.0",
273 | "@typescript-eslint/eslint-plugin": "^5.62.0",
274 | "@typescript-eslint/parser": "^5.62.0",
275 | "@vscode/test-electron": "^2.3.8",
276 | "@vscode/vsce": "^2.24.0",
277 | "esbuild": "^0.19.12",
278 | "eslint": "^8.47.0",
279 | "fast-glob": "^3.3.2",
280 | "mocha": "^9.2.2",
281 | "npm-run-all": "^4.1.5",
282 | "typescript": "^4.9.5"
283 | },
284 | "dependencies": {
285 | "puppeteer-core": "^24.2.0"
286 | },
287 | "npm": {
288 | "peerDependencyRules": {
289 | "ignoreMissing": [
290 | "@types/node",
291 | "typescript",
292 | "eslint",
293 | "@types/vscode"
294 | ],
295 | "allowAny": [
296 | "@types/node"
297 | ]
298 | }
299 | }
300 | }
301 |
--------------------------------------------------------------------------------
/src/browser/commands/index.ts:
--------------------------------------------------------------------------------
1 | import { BrowserMonitor } from "../monitor";
2 | import { ComposerIntegration } from "../../shared/composer/integration";
3 | import { ToastService } from "../../shared/utils/toast";
4 |
5 | export class BrowserCommandHandlers {
6 | private browserMonitor: BrowserMonitor;
7 | private composerIntegration: ComposerIntegration;
8 | private toastService: ToastService;
9 |
10 | constructor(
11 | browserMonitor: BrowserMonitor,
12 | composerIntegration: ComposerIntegration
13 | ) {
14 | this.browserMonitor = browserMonitor;
15 | this.composerIntegration = composerIntegration;
16 | this.toastService = ToastService.getInstance();
17 | }
18 |
19 | public async handleSmartCapture(): Promise {
20 | if (this.browserMonitor.isPageConnected()) {
21 | await this.handleCapture();
22 | } else {
23 | await this.handleConnect();
24 | }
25 | }
26 |
27 | public async handleClearLogs(): Promise {
28 | if (!this.browserMonitor.isPageConnected()) {
29 | this.toastService.showNoTabConnected();
30 | return;
31 | }
32 |
33 | const confirmed = await this.toastService.showConfirmation(
34 | "Are you sure you want to clear all browser logs?"
35 | );
36 |
37 | if (confirmed) {
38 | this.browserMonitor.clearLogs();
39 | this.toastService.showLogsClearedSuccess();
40 | }
41 | }
42 |
43 | public async handleSendLogs(): Promise {
44 | if (!this.browserMonitor.isPageConnected()) {
45 | this.toastService.showNoTabConnected();
46 | return;
47 | }
48 |
49 | try {
50 | await this.toastService.showProgress("Sending Logs", async () => {
51 | await this.composerIntegration.sendToComposer(
52 | undefined,
53 | this.browserMonitor.getLogs()
54 | );
55 | });
56 | this.toastService.showLogsSentSuccess();
57 | } catch (error: unknown) {
58 | const msg = error instanceof Error ? error.message : String(error);
59 | this.toastService.showError(`Failed to send logs: ${msg}`);
60 | }
61 | }
62 |
63 | public async handleSendScreenshot(): Promise {
64 | if (!this.browserMonitor.isPageConnected()) {
65 | this.toastService.showNoTabConnected();
66 | return;
67 | }
68 |
69 | try {
70 | await this.toastService.showProgress("Capturing Screenshot", async () => {
71 | const page = await this.browserMonitor.getPageForScreenshot();
72 | if (!page) {
73 | throw new Error("Page not accessible");
74 | }
75 |
76 | const screenshot = await page.screenshot({
77 | type: "png",
78 | fullPage: true,
79 | encoding: "binary",
80 | });
81 |
82 | await this.composerIntegration.sendToComposer(
83 | Buffer.from(screenshot),
84 | undefined
85 | );
86 | });
87 | this.toastService.showScreenshotSentSuccess();
88 | } catch (error: unknown) {
89 | const msg = error instanceof Error ? error.message : String(error);
90 | if (msg.includes("Target closed") || msg.includes("Target crashed")) {
91 | this.toastService.showPageClosedOrCrashed();
92 | await this.browserMonitor.disconnect();
93 | } else {
94 | this.toastService.showError(`Screenshot capture failed: ${msg}`);
95 | }
96 | }
97 | }
98 |
99 | private async handleConnect(): Promise {
100 | try {
101 | await this.browserMonitor.connect();
102 | this.toastService.showConnectionSuccess();
103 | } catch (error: unknown) {
104 | const msg = error instanceof Error ? error.message : String(error);
105 | this.toastService.showError(`Failed to connect: ${msg}`);
106 | }
107 | }
108 |
109 | private async handleCapture(): Promise {
110 | try {
111 | if (!this.browserMonitor.isPageConnected()) {
112 | this.toastService.showNoTabConnected();
113 | return;
114 | }
115 |
116 | await this.toastService.showProgress("Capturing Tab Info", async () => {
117 | const activePage = this.browserMonitor.getActivePage();
118 | if (!activePage) {
119 | throw new Error("No active page found");
120 | }
121 |
122 | const page = await this.browserMonitor.getPageForScreenshot();
123 | if (!page) {
124 | throw new Error("Page not accessible");
125 | }
126 |
127 | const screenshot = await page.screenshot({
128 | type: "png",
129 | fullPage: true,
130 | encoding: "binary",
131 | });
132 |
133 | await this.composerIntegration.sendToComposer(
134 | Buffer.from(screenshot),
135 | this.browserMonitor.getLogs()
136 | );
137 | });
138 | } catch (error: unknown) {
139 | const msg = error instanceof Error ? error.message : String(error);
140 | if (msg.includes("Target closed") || msg.includes("Target crashed")) {
141 | this.toastService.showPageClosedOrCrashed();
142 | await this.browserMonitor.disconnect();
143 | } else {
144 | this.toastService.showError(`Capture failed: ${msg}`);
145 | }
146 | }
147 | }
148 | }
149 |
--------------------------------------------------------------------------------
/src/browser/events/event-handlers.ts:
--------------------------------------------------------------------------------
1 | import * as puppeteer from "puppeteer-core";
2 | import { BrowserLogHandler } from "../utils/log-handler";
3 |
4 | export class BrowserEventHandlers {
5 | private logHandler: BrowserLogHandler;
6 |
7 | constructor(logHandler: BrowserLogHandler) {
8 | this.logHandler = logHandler;
9 | }
10 |
11 | public setupEventListeners(client: puppeteer.CDPSession): void {
12 | client.on("Runtime.consoleAPICalled", (e) => {
13 | this.logHandler.handleConsoleMessage(e);
14 | });
15 |
16 | client.on("Runtime.exceptionThrown", (e) => {
17 | this.logHandler.handleConsoleMessage({
18 | type: "error",
19 | args: [
20 | {
21 | type: "error",
22 | subtype: "error",
23 | description:
24 | e.exceptionDetails.exception?.description ||
25 | e.exceptionDetails.text,
26 | preview: {
27 | properties: e.exceptionDetails.stackTrace
28 | ? [
29 | {
30 | name: "stack",
31 | value: e.exceptionDetails.stackTrace.callFrames
32 | .map(
33 | (frame) =>
34 | ` at ${frame.functionName || "(anonymous)"} (${
35 | frame.url
36 | }:${frame.lineNumber + 1}:${
37 | frame.columnNumber + 1
38 | })`
39 | )
40 | .join("\n"),
41 | },
42 | ]
43 | : [],
44 | },
45 | },
46 | ],
47 | });
48 | });
49 |
50 | client.on("Network.requestWillBeSent", (request) => {
51 | this.logHandler.handleNetworkRequest(request);
52 | });
53 |
54 | client.on("Network.responseReceived", (response) => {
55 | this.logHandler.updateNetworkResponse(response);
56 | });
57 |
58 | client.on("Log.entryAdded", (entry) => {
59 | // Map browser log entry to console entry
60 | this.logHandler.handleConsoleMessage({
61 | type: entry.entry.level,
62 | args: [
63 | {
64 | type: "string",
65 | value: entry.entry.text,
66 | },
67 | ],
68 | });
69 | });
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/src/browser/monitor.ts:
--------------------------------------------------------------------------------
1 | import * as vscode from "vscode";
2 | import * as puppeteer from "puppeteer-core";
3 | import { EventEmitter } from "events";
4 | import { MonitoredPage, BrowserLog, NetworkRequest } from "../shared/types";
5 | import { ToastService } from "../shared/utils/toast";
6 | import { ErrorHandler, BrowserError } from "../shared/utils/error-handler";
7 |
8 | import { BrowserLogHandler } from "./utils/log-handler";
9 | import { PageManager } from "./services/page-manager";
10 | import { BrowserStatusBar } from "./services/status-bar";
11 | import { HealthChecker } from "./services/health-checker";
12 | import { BrowserEventHandlers } from "./events/event-handlers";
13 |
14 | export class BrowserMonitor extends EventEmitter {
15 | private static instance: BrowserMonitor;
16 | private pageManager: PageManager;
17 | private healthChecker: HealthChecker;
18 | private toastService: ToastService;
19 | private errorHandler: ErrorHandler;
20 | private isConnected: boolean = false;
21 | private disconnectEmitter = new vscode.EventEmitter();
22 |
23 | // Modularized components
24 | private logHandler: BrowserLogHandler;
25 | private statusBar: BrowserStatusBar;
26 | private eventHandlers: BrowserEventHandlers;
27 |
28 | private constructor() {
29 | super();
30 | this.pageManager = PageManager.getInstance();
31 | this.healthChecker = HealthChecker.getInstance();
32 | this.toastService = ToastService.getInstance();
33 | this.errorHandler = ErrorHandler.getInstance();
34 |
35 | // Initialize all components
36 | this.logHandler = new BrowserLogHandler();
37 | this.statusBar = new BrowserStatusBar();
38 | this.eventHandlers = new BrowserEventHandlers(this.logHandler);
39 |
40 | // Set up health checker disconnect handler
41 | this.healthChecker.onDisconnect(() => {
42 | this.disconnectEmitter.fire();
43 | });
44 | }
45 |
46 | public static getInstance(): BrowserMonitor {
47 | if (!BrowserMonitor.instance) {
48 | BrowserMonitor.instance = new BrowserMonitor();
49 | }
50 | return BrowserMonitor.instance;
51 | }
52 |
53 | public async connect(): Promise {
54 | try {
55 | if (this.isConnected) {
56 | await this.disconnect();
57 | }
58 |
59 | await this.pageManager.connect();
60 | const pages = await this.pageManager.getPages();
61 |
62 | if (!pages?.length) {
63 | throw new BrowserError(
64 | "No open pages found. Please open at least one tab in Chrome."
65 | );
66 | }
67 |
68 | // Let user select page
69 | const selection = await vscode.window.showQuickPick(pages, {
70 | placeHolder: "Select the webpage to monitor",
71 | matchOnDescription: true,
72 | });
73 |
74 | if (!selection) {
75 | return;
76 | }
77 |
78 | const client = await this.pageManager.setActivePage(
79 | selection.page,
80 | selection.info
81 | );
82 | this.eventHandlers.setupEventListeners(client);
83 | this.isConnected = true;
84 | this.statusBar.setConnected(true);
85 | await this.showSuccessNotification();
86 | this.startHealthCheck();
87 | } catch (error) {
88 | this.errorHandler.handleBrowserError(
89 | error,
90 | "Failed to connect to browser"
91 | );
92 | this.isConnected = false;
93 | this.statusBar.setConnected(false);
94 | throw error;
95 | }
96 | }
97 |
98 | public async disconnect(): Promise {
99 | try {
100 | await this.pageManager.disconnect();
101 | this.isConnected = false;
102 | this.statusBar.setConnected(false);
103 | this.stopHealthCheck();
104 | this.disconnectEmitter.fire();
105 | } catch (error) {
106 | this.errorHandler.handleBrowserError(
107 | error,
108 | "Failed to disconnect from browser"
109 | );
110 | throw error;
111 | }
112 | }
113 |
114 | public onDisconnect(listener: () => void): vscode.Disposable {
115 | return this.disconnectEmitter.event(listener);
116 | }
117 |
118 | public isPageConnected(): boolean {
119 | return this.isConnected && this.pageManager.getActivePage() !== null;
120 | }
121 |
122 | public getLogs(): { console: BrowserLog[]; network: NetworkRequest[] } {
123 | if (!this.isPageConnected()) {
124 | throw new BrowserError("No browser tab connected");
125 | }
126 | return this.logHandler.getLogs();
127 | }
128 |
129 | public clearLogs(): void {
130 | if (!this.isPageConnected()) {
131 | throw new BrowserError("No browser tab connected");
132 | }
133 | this.logHandler.clearLogs();
134 | this.toastService.showLogsClearedSuccess();
135 | }
136 |
137 | private startHealthCheck(): void {
138 | this.healthChecker.startChecking(async () => {
139 | try {
140 | const isHealthy = await this.pageManager.checkPageAccessible();
141 | if (!isHealthy) {
142 | await this.handleSessionError();
143 | }
144 | } catch (error) {
145 | this.errorHandler.handleBrowserError(error, "Health check failed");
146 | await this.handleSessionError();
147 | }
148 | });
149 | }
150 |
151 | private stopHealthCheck(): void {
152 | this.healthChecker.clearHealthCheck();
153 | }
154 |
155 | private async showSuccessNotification() {
156 | const activePage = this.pageManager.getActivePage();
157 | if (activePage) {
158 | this.toastService.showInfo(
159 | `Successfully connected to: ${activePage.title}`
160 | );
161 | }
162 | }
163 |
164 | private async handleSessionError() {
165 | this.toastService.showError(
166 | "Browser connection lost. The tab may have been closed or navigated away."
167 | );
168 | await this.disconnect();
169 | }
170 |
171 | public getActivePage(): MonitoredPage | null {
172 | return this.pageManager.getActivePage();
173 | }
174 |
175 | public dispose() {
176 | this.disconnect();
177 | this.statusBar.dispose();
178 | this.healthChecker.dispose();
179 | this.disconnectEmitter.dispose();
180 | }
181 |
182 | public async getPageForScreenshot(): Promise {
183 | if (!this.isConnected) {
184 | return null;
185 | }
186 | return this.pageManager.getActivePageForScreenshot();
187 | }
188 | }
189 |
--------------------------------------------------------------------------------
/src/browser/services/health-checker.ts:
--------------------------------------------------------------------------------
1 | import * as vscode from "vscode";
2 |
3 | export class HealthChecker {
4 | private static instance: HealthChecker;
5 | private healthCheckInterval: NodeJS.Timeout | null = null;
6 | private disconnectEmitter = new vscode.EventEmitter();
7 |
8 | private constructor() {}
9 |
10 | public static getInstance(): HealthChecker {
11 | if (!HealthChecker.instance) {
12 | HealthChecker.instance = new HealthChecker();
13 | }
14 | return HealthChecker.instance;
15 | }
16 |
17 | public startChecking(checkFunction: () => Promise): void {
18 | this.clearHealthCheck();
19 |
20 | this.healthCheckInterval = setInterval(async () => {
21 | try {
22 | await checkFunction();
23 | } catch (error) {
24 | this.clearHealthCheck();
25 | this.disconnectEmitter.fire();
26 | }
27 | }, 5000);
28 | }
29 |
30 | public clearHealthCheck(): void {
31 | if (this.healthCheckInterval) {
32 | clearInterval(this.healthCheckInterval);
33 | this.healthCheckInterval = null;
34 | }
35 | }
36 |
37 | public onDisconnect(listener: () => void): vscode.Disposable {
38 | return this.disconnectEmitter.event(listener);
39 | }
40 |
41 | public dispose(): void {
42 | this.clearHealthCheck();
43 | this.disconnectEmitter.dispose();
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/src/browser/services/page-manager.ts:
--------------------------------------------------------------------------------
1 | import * as puppeteer from "puppeteer-core";
2 | import { MonitoredPage } from "../../shared/types";
3 |
4 | export class PageManager {
5 | private static instance: PageManager;
6 | private browser: puppeteer.Browser | null = null;
7 | private activePage: {
8 | page: puppeteer.Page;
9 | client: puppeteer.CDPSession;
10 | info: MonitoredPage;
11 | } | null = null;
12 |
13 | private constructor() {}
14 |
15 | public static getInstance(): PageManager {
16 | if (!PageManager.instance) {
17 | PageManager.instance = new PageManager();
18 | }
19 | return PageManager.instance;
20 | }
21 |
22 | public async connect(): Promise {
23 | const browserURL = "http://localhost:9222";
24 | await this.connectToBrowser(browserURL);
25 | }
26 |
27 | private async connectToBrowser(
28 | browserURL: string
29 | ): Promise {
30 | const puppeteer = await import("puppeteer-core");
31 | this.browser = await puppeteer.connect({
32 | browserURL,
33 | defaultViewport: null,
34 | });
35 | return this.browser;
36 | }
37 |
38 | public async getPages(): Promise<
39 | Array<{
40 | label: string;
41 | description: string;
42 | page: puppeteer.Page;
43 | info: MonitoredPage;
44 | }>
45 | > {
46 | if (!this.browser) {
47 | throw new Error("Browser not connected");
48 | }
49 |
50 | const pages = await this.browser.pages();
51 | if (!pages?.length) {
52 | throw new Error(
53 | "No open pages found. Please open at least one tab in Chrome."
54 | );
55 | }
56 |
57 | const picks = await Promise.all(
58 | pages.map(async (page) => {
59 | let title = "";
60 | let url = "";
61 | try {
62 | title = await page.title();
63 | url = await page.url();
64 | } catch {
65 | // Ignore errors and use empty strings
66 | }
67 | return {
68 | label: title || url || "Untitled Page",
69 | description: url,
70 | page,
71 | info: { title, url, id: url },
72 | };
73 | })
74 | );
75 |
76 | return picks;
77 | }
78 |
79 | public async setActivePage(
80 | page: puppeteer.Page,
81 | pageInfo: MonitoredPage
82 | ): Promise {
83 | const client = await page.createCDPSession();
84 |
85 | try {
86 | await Promise.all([
87 | client.send("Page.enable"),
88 | client.send("Network.enable"),
89 | client.send("Runtime.enable"),
90 | client.send("Log.enable"),
91 | ]);
92 | } catch (error) {
93 | throw new Error("Failed to initialize browser session");
94 | }
95 |
96 | this.activePage = { page, client, info: pageInfo };
97 |
98 | // Set up console log capture
99 | await page.evaluate(() => {
100 | const originalConsole = {
101 | log: console.log.bind(console),
102 | warn: console.warn.bind(console),
103 | error: console.error.bind(console),
104 | info: console.info.bind(console),
105 | debug: console.debug.bind(console),
106 | };
107 |
108 | type ConsoleMethod = keyof typeof originalConsole;
109 |
110 | (Object.keys(originalConsole) as ConsoleMethod[]).forEach((method) => {
111 | console[method] = function (...args: unknown[]) {
112 | originalConsole[method](...args);
113 | };
114 | });
115 | });
116 |
117 | return client;
118 | }
119 |
120 | public getActivePage(): MonitoredPage | null {
121 | return this.activePage?.info || null;
122 | }
123 |
124 | public getActivePageForScreenshot(): puppeteer.Page | null {
125 | return this.activePage?.page || null;
126 | }
127 |
128 | public async checkPageAccessible(): Promise {
129 | if (!this.activePage?.page || !this.activePage?.client) {
130 | return false;
131 | }
132 |
133 | try {
134 | await Promise.all([
135 | this.activePage.client.send("Runtime.evaluate", { expression: "1" }),
136 | this.activePage.page.evaluate(() => true),
137 | ]);
138 | return true;
139 | } catch (error) {
140 | return false;
141 | }
142 | }
143 |
144 | public setupPageEventListeners(
145 | page: puppeteer.Page,
146 | onClose: () => void
147 | ): void {
148 | page.on("close", onClose);
149 | page.on("crash", onClose);
150 | page.on("detach", onClose);
151 | page.on("targetdestroyed", onClose);
152 | }
153 |
154 | public async stopMonitoring(): Promise {
155 | if (this.activePage?.client) {
156 | try {
157 | await this.activePage.client.detach();
158 | } catch (error) {
159 | // Ignore detach errors
160 | }
161 | }
162 | this.activePage = null;
163 | }
164 |
165 | public async disconnect(): Promise {
166 | await this.stopMonitoring();
167 | if (this.browser) {
168 | try {
169 | await this.browser.disconnect();
170 | } catch (error) {
171 | // Ignore disconnect errors
172 | }
173 | }
174 | this.browser = null;
175 | }
176 | }
177 |
--------------------------------------------------------------------------------
/src/browser/services/status-bar.ts:
--------------------------------------------------------------------------------
1 | import * as vscode from "vscode";
2 | import { MonitoredPage } from "../../shared/types";
3 |
4 | export class BrowserStatusBar {
5 | private statusBarItem: vscode.StatusBarItem;
6 | private isConnected: boolean = false;
7 | private activePage: MonitoredPage | null = null;
8 |
9 | constructor() {
10 | this.statusBarItem = vscode.window.createStatusBarItem(
11 | vscode.StatusBarAlignment.Right,
12 | 100
13 | );
14 | this.statusBarItem.command = "web-preview.smartCapture";
15 | this.update();
16 | }
17 |
18 | public setConnected(connected: boolean): void {
19 | this.isConnected = connected;
20 | this.update();
21 | }
22 |
23 | public setActivePage(page: MonitoredPage | null): void {
24 | this.activePage = page;
25 | this.update();
26 | }
27 |
28 | public update(): void {
29 | if (!this.isConnected) {
30 | this.statusBarItem.text = "$(plug) Connect Browser Tab";
31 | } else {
32 | this.statusBarItem.text = `$(eye) Capture Tab Info (${this.activePage?.title})`;
33 | }
34 | this.statusBarItem.tooltip = this.isConnected
35 | ? `Connected to: ${this.activePage?.url}`
36 | : "Click to connect to a browser tab";
37 | this.statusBarItem.show();
38 | }
39 |
40 | public dispose(): void {
41 | this.statusBarItem.dispose();
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/src/browser/utils/log-handler.ts:
--------------------------------------------------------------------------------
1 | import { BrowserLog, NetworkRequest, LogData } from "../../shared/types";
2 | import { LogFilterManager } from "../../shared/config/log-filters";
3 |
4 | export interface ExtendedNetworkRequest extends NetworkRequest {
5 | id: string;
6 | headers?: Record;
7 | startTime: string;
8 | type?: string;
9 | }
10 |
11 | export class BrowserLogHandler {
12 | private consoleLogs: BrowserLog[] = [];
13 | private networkLogs: ExtendedNetworkRequest[] = [];
14 | private logFilterManager: LogFilterManager;
15 |
16 | constructor() {
17 | this.logFilterManager = LogFilterManager.getInstance();
18 | }
19 |
20 | public clearLogs(): void {
21 | this.consoleLogs = [];
22 | this.networkLogs = [];
23 | }
24 |
25 | public getLogs(): LogData {
26 | return {
27 | console: this.applyLogFilters(this.consoleLogs),
28 | network: this.networkLogs,
29 | };
30 | }
31 |
32 | private applyLogFilters(logs: BrowserLog[]): BrowserLog[] {
33 | const filters = this.shouldShowFilteredLogs() ? [] : this.getFilters();
34 | if (!filters.length) {
35 | return logs;
36 | }
37 |
38 | return logs.filter((log) => {
39 | const messageString =
40 | typeof log.message === "string" ? log.message : log.args.join(" ");
41 |
42 | return !filters.some((filter) => {
43 | const regex = new RegExp(filter, "i");
44 | return regex.test(messageString);
45 | });
46 | });
47 | }
48 |
49 | private shouldShowFilteredLogs(): boolean {
50 | return this.logFilterManager.getFilters().console.log;
51 | }
52 |
53 | private getFilters(): string[] {
54 | // Since getActiveFilters is not available, we'll use an empty array
55 | // You may need to implement this method or modify the LogFilterManager
56 | return [];
57 | }
58 |
59 | public handleConsoleMessage(e: any): void {
60 | const formattedArgs: string[] = [];
61 |
62 | let trace = "";
63 | if (e.stackTrace) {
64 | trace = e.stackTrace.callFrames
65 | .map(
66 | (frame: any) =>
67 | ` at ${frame.functionName || "(anonymous)"} (${frame.url}:${
68 | frame.lineNumber + 1
69 | }:${frame.columnNumber + 1})`
70 | )
71 | .join("\n");
72 | }
73 |
74 | for (const arg of e.args) {
75 | if (arg.type === "object" && arg.preview) {
76 | if (Array.isArray(arg.preview.properties)) {
77 | const props = arg.preview.properties
78 | .map(
79 | (p: any) =>
80 | `${p.name}: ${
81 | typeof p.value === "string"
82 | ? `"${p.value}"`
83 | : p.value || "undefined"
84 | }`
85 | )
86 | .join(", ");
87 | formattedArgs.push(`Object {${props}}`);
88 | } else {
89 | formattedArgs.push(arg.preview.description || "Object {}");
90 | }
91 | } else if (arg.type === "function") {
92 | formattedArgs.push(arg.description || "function");
93 | } else if (arg.type === "undefined") {
94 | formattedArgs.push("undefined");
95 | } else if (arg.type === "string") {
96 | formattedArgs.push(arg.value);
97 | } else if (arg.type === "number" || arg.type === "boolean") {
98 | formattedArgs.push(String(arg.value));
99 | } else if (arg.type === "symbol") {
100 | formattedArgs.push(arg.description || "Symbol()");
101 | } else if ("subtype" in arg && arg.subtype === "error") {
102 | if (arg.description && arg.description.includes("\n")) {
103 | formattedArgs.push(arg.description);
104 | } else {
105 | const stack =
106 | arg.preview?.properties?.find((p: any) => p.name === "stack")
107 | ?.value || "";
108 | formattedArgs.push(
109 | `${arg.description || "Error"}${stack ? `\n${stack}` : ""}`
110 | );
111 | }
112 | } else {
113 | formattedArgs.push(String(arg));
114 | }
115 | }
116 |
117 | if (trace) {
118 | formattedArgs[0] = `Trace: ${formattedArgs[0] || "console.trace"}`;
119 | }
120 |
121 | const timestamp = Date.now();
122 | const message = formattedArgs.join(" ");
123 |
124 | this.consoleLogs.push({
125 | type: e.type,
126 | args: formattedArgs,
127 | timestamp,
128 | message,
129 | });
130 | }
131 |
132 | public handleNetworkRequest(request: any): void {
133 | this.networkLogs.push({
134 | id: request.requestId,
135 | url: request.request.url,
136 | method: request.request.method,
137 | status: 0,
138 | timestamp: Date.now(),
139 | startTime: new Date().toISOString(),
140 | duration: 0,
141 | type: request.type || "",
142 | headers: request.request.headers,
143 | });
144 | }
145 |
146 | public updateNetworkResponse(response: any): void {
147 | const request = this.networkLogs.find(
148 | (req) => req.id === response.requestId
149 | );
150 | if (request) {
151 | request.status = response.response.status;
152 | request.headers = {
153 | ...request.headers,
154 | ...response.response.headers,
155 | };
156 | if (response.response.timing) {
157 | const now = new Date();
158 | const startTime = new Date(request.startTime);
159 | request.duration = now.getTime() - startTime.getTime();
160 | }
161 | }
162 | }
163 | }
164 |
--------------------------------------------------------------------------------
/src/commands/index.ts:
--------------------------------------------------------------------------------
1 | import { BrowserMonitor } from "../browser/monitor";
2 | import { ComposerIntegration } from "../shared/composer/integration";
3 | import { iOSSimulatorMonitor } from "../ios/simulator";
4 | import { BrowserCommandHandlers } from "../browser/commands";
5 | import { iOSCommandHandlers } from "../ios/commands";
6 |
7 | export class CommandHandlers {
8 | private browserCommandHandlers: BrowserCommandHandlers;
9 | private iOSCommandHandlers: iOSCommandHandlers;
10 |
11 | constructor(
12 | browserMonitor: BrowserMonitor,
13 | composerIntegration: ComposerIntegration,
14 | iOSSimulatorMonitor: iOSSimulatorMonitor
15 | ) {
16 | this.browserCommandHandlers = new BrowserCommandHandlers(
17 | browserMonitor,
18 | composerIntegration
19 | );
20 | this.iOSCommandHandlers = new iOSCommandHandlers(
21 | iOSSimulatorMonitor,
22 | composerIntegration
23 | );
24 | }
25 |
26 | // Browser commands
27 | public async handleSmartCapture(): Promise {
28 | return this.browserCommandHandlers.handleSmartCapture();
29 | }
30 |
31 | public async handleClearLogs(): Promise {
32 | return this.browserCommandHandlers.handleClearLogs();
33 | }
34 |
35 | public async handleSendLogs(): Promise {
36 | return this.browserCommandHandlers.handleSendLogs();
37 | }
38 |
39 | public async handleSendScreenshot(): Promise {
40 | return this.browserCommandHandlers.handleSendScreenshot();
41 | }
42 |
43 | // iOS commands
44 | public async handleConnectiOSSimulator(): Promise {
45 | return this.iOSCommandHandlers.handleConnectiOSSimulator();
46 | }
47 |
48 | public async handleSendiOSScreenshot(): Promise {
49 | return this.iOSCommandHandlers.handleSendiOSScreenshot();
50 | }
51 |
52 | public async handleCaptureiOS(): Promise {
53 | return this.iOSCommandHandlers.handleCaptureiOS();
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/src/extension.ts:
--------------------------------------------------------------------------------
1 | import * as vscode from "vscode";
2 | import { ComposerIntegration } from "./shared/composer/integration";
3 | import { BrowserMonitor } from "./browser/monitor";
4 | import { CommandHandlers } from "./commands";
5 | import { ToastService } from "./shared/utils/toast";
6 | import { SettingsPanel } from "./views/settings-panel";
7 | import { LogFilterManager } from "./shared/config/log-filters";
8 | import { FeatureToggleManager } from "./shared/config/feature-toggles";
9 | import { iOSSimulatorMonitor } from "./ios/simulator";
10 |
11 | export function activate(context: vscode.ExtensionContext) {
12 | const composerIntegration = ComposerIntegration.getInstance(context);
13 | const browserMonitor = BrowserMonitor.getInstance();
14 | const iosMonitor = iOSSimulatorMonitor.getInstance();
15 | const commandHandlers = new CommandHandlers(
16 | browserMonitor,
17 | composerIntegration,
18 | iosMonitor
19 | );
20 | const toastService = ToastService.getInstance();
21 | const settingsPanel = SettingsPanel.getInstance();
22 | const logFilterManager = LogFilterManager.getInstance();
23 | const featureToggleManager = FeatureToggleManager.getInstance();
24 |
25 | // Initialize managers
26 | logFilterManager.initialize(context);
27 | featureToggleManager.initialize(context);
28 |
29 | // Set iOSFeaturesEnabled context for menu visibility
30 | vscode.commands.executeCommand(
31 | "setContext",
32 | "web-preview:iOSFeaturesEnabled",
33 | featureToggleManager.isiOSFeaturesEnabled()
34 | );
35 |
36 | // Update iOS simulator status bar visibility based on feature toggle
37 | iosMonitor.updateStatusBar();
38 |
39 | // Subscribe to iOS simulator disconnect events
40 | const iosDisconnectSubscription = iosMonitor.onDisconnect(() => {
41 | toastService.showiOSSimulatorDisconnected();
42 | });
43 |
44 | context.subscriptions.push(
45 | vscode.commands.registerCommand("web-preview.smartCapture", () =>
46 | commandHandlers.handleSmartCapture()
47 | ),
48 | vscode.commands.registerCommand("web-preview.clearLogs", () =>
49 | commandHandlers.handleClearLogs()
50 | ),
51 | vscode.commands.registerCommand("web-preview.sendLogs", () =>
52 | commandHandlers.handleSendLogs()
53 | ),
54 | vscode.commands.registerCommand("web-preview.sendScreenshot", () =>
55 | commandHandlers.handleSendScreenshot()
56 | ),
57 | vscode.commands.registerCommand("web-preview.openSettings", () =>
58 | SettingsPanel.show()
59 | ),
60 | // iOS simulator commands
61 | vscode.commands.registerCommand("web-preview.connectiOSSimulator", () =>
62 | commandHandlers.handleConnectiOSSimulator()
63 | ),
64 | vscode.commands.registerCommand("web-preview.sendiOSScreenshot", () =>
65 | commandHandlers.handleSendiOSScreenshot()
66 | ),
67 | vscode.commands.registerCommand("web-preview.captureiOS", () =>
68 | commandHandlers.handleCaptureiOS()
69 | ),
70 | vscode.window.registerWebviewViewProvider(
71 | SettingsPanel.viewType,
72 | settingsPanel
73 | ),
74 | iosDisconnectSubscription
75 | );
76 |
77 | // Subscribe to browser disconnect events
78 | browserMonitor.onDisconnect(() => {
79 | toastService.showBrowserDisconnected();
80 | });
81 | }
82 |
83 | export function deactivate() {}
84 |
--------------------------------------------------------------------------------
/src/ios/commands/index.ts:
--------------------------------------------------------------------------------
1 | import { iOSSimulatorMonitor } from "../simulator";
2 | import { ComposerIntegration } from "../../shared/composer/integration";
3 | import { ToastService } from "../../shared/utils/toast";
4 |
5 | export class iOSCommandHandlers {
6 | private iOSSimulatorMonitor: iOSSimulatorMonitor;
7 | private composerIntegration: ComposerIntegration;
8 | private toastService: ToastService;
9 |
10 | constructor(
11 | iOSSimulatorMonitor: iOSSimulatorMonitor,
12 | composerIntegration: ComposerIntegration
13 | ) {
14 | this.iOSSimulatorMonitor = iOSSimulatorMonitor;
15 | this.composerIntegration = composerIntegration;
16 | this.toastService = ToastService.getInstance();
17 | }
18 |
19 | public async handleConnectiOSSimulator(): Promise {
20 | try {
21 | await this.iOSSimulatorMonitor.connect();
22 | } catch (error: unknown) {
23 | const msg = error instanceof Error ? error.message : String(error);
24 | this.toastService.showError(`Failed to connect to iOS simulator: ${msg}`);
25 | }
26 | }
27 |
28 | public async handleSendiOSScreenshot(): Promise {
29 | if (!this.iOSSimulatorMonitor.isSimulatorConnected()) {
30 | this.toastService.showError("No iOS simulator connected");
31 | return;
32 | }
33 |
34 | try {
35 | await this.toastService.showProgress(
36 | "Capturing iOS Screenshot",
37 | async () => {
38 | const screenshot = await this.iOSSimulatorMonitor.captureScreenshot();
39 | await this.composerIntegration.sendiOSToComposer(screenshot);
40 | }
41 | );
42 | this.toastService.showInfo("iOS screenshot sent successfully");
43 | } catch (error: unknown) {
44 | const msg = error instanceof Error ? error.message : String(error);
45 | this.toastService.showError(`iOS screenshot capture failed: ${msg}`);
46 | }
47 | }
48 |
49 | public async handleCaptureiOS(): Promise {
50 | if (!this.iOSSimulatorMonitor.isSimulatorConnected()) {
51 | this.toastService.showError("No iOS simulator connected");
52 | return;
53 | }
54 |
55 | try {
56 | await this.toastService.showProgress(
57 | "Capturing iOS Screenshot",
58 | async () => {
59 | const screenshot = await this.iOSSimulatorMonitor.captureScreenshot();
60 | await this.composerIntegration.sendiOSToComposer(screenshot);
61 | }
62 | );
63 | this.toastService.showInfo("iOS screenshot captured successfully");
64 | } catch (error: unknown) {
65 | const msg = error instanceof Error ? error.message : String(error);
66 | this.toastService.showError(`iOS capture failed: ${msg}`);
67 | }
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/src/ios/services/simulator-scanner.ts:
--------------------------------------------------------------------------------
1 | import { exec } from "child_process";
2 | import { promisify } from "util";
3 | import { iOSSimulatorInfo, iOSApp } from "../../shared/types";
4 |
5 | const execAsync = promisify(exec);
6 |
7 | export class SimulatorScanner {
8 | constructor() {}
9 |
10 | public async getAvailableSimulators(): Promise {
11 | try {
12 | // Get list of simulators
13 | const { stdout } = await execAsync("xcrun simctl list devices -j");
14 |
15 | const allSimulators: iOSSimulatorInfo[] = [];
16 | const simData = JSON.parse(stdout);
17 |
18 | // Process each runtime
19 | if (simData.devices) {
20 | for (const runtime in simData.devices) {
21 | const runtimeName = runtime.replace(
22 | "com.apple.CoreSimulator.SimRuntime.",
23 | ""
24 | );
25 | const devices = simData.devices[runtime];
26 |
27 | for (const device of devices) {
28 | if (device.isAvailable) {
29 | allSimulators.push({
30 | name: device.name,
31 | udid: device.udid,
32 | runtime: runtimeName,
33 | status: device.state,
34 | });
35 | }
36 | }
37 | }
38 | }
39 |
40 | // First try to get only booted simulators
41 | const bootedSimulators = allSimulators.filter(
42 | (sim) => sim.status === "Booted"
43 | );
44 |
45 | // If there are booted simulators, return only those
46 | // Otherwise return all available simulators
47 | return bootedSimulators.length > 0 ? bootedSimulators : allSimulators;
48 | } catch (error) {
49 | console.error("Error getting simulators:", error);
50 | throw new Error(
51 | "Failed to get iOS simulators. Make sure Xcode is installed."
52 | );
53 | }
54 | }
55 |
56 | public async getInstalledApps(udid: string): Promise {
57 | const apps: iOSApp[] = [];
58 |
59 | try {
60 | // Get list of installed apps from the simulator
61 | const { stdout } = await execAsync(`xcrun simctl listapps ${udid}`);
62 |
63 | console.log("Debug - Raw output:", stdout);
64 |
65 | // Split the output into app entries
66 | const appEntries = stdout.split(/(?=\s*"[^"]+"\s*=\s*{)/);
67 |
68 | for (const entry of appEntries) {
69 | if (!entry.trim()) continue;
70 |
71 | try {
72 | // Extract bundle ID from the first line
73 | const bundleIdMatch = entry.match(/"([^"]+)"\s*=/);
74 | if (!bundleIdMatch) continue;
75 | const bundleId = bundleIdMatch[1];
76 |
77 | // Check if this is a user app
78 | if (!entry.includes("ApplicationType = User")) continue;
79 |
80 | // Extract display name
81 | let name = "";
82 | const displayNameMatch = entry.match(
83 | /CFBundleDisplayName\s*=\s*([^;\n]+)/
84 | );
85 | const bundleNameMatch = entry.match(/CFBundleName\s*=\s*([^;\n]+)/);
86 |
87 | if (displayNameMatch) {
88 | name = displayNameMatch[1].trim();
89 | } else if (bundleNameMatch) {
90 | name = bundleNameMatch[1].trim();
91 | }
92 |
93 | // Clean up the name (remove quotes if present)
94 | name = name.replace(/^"|"$/g, "").trim();
95 |
96 | if (name && bundleId) {
97 | console.log(`Debug - Found user app: ${name} (${bundleId})`);
98 | apps.push({ name, bundleId });
99 | }
100 | } catch (parseError) {
101 | console.error("Error parsing app entry:", parseError);
102 | continue;
103 | }
104 | }
105 |
106 | console.log(`Debug - Found ${apps.length} user-installed apps`);
107 |
108 | if (apps.length === 0) {
109 | throw new Error("No user-installed apps found in simulator");
110 | }
111 |
112 | return apps;
113 | } catch (error) {
114 | console.error("Error getting installed apps:", error);
115 | throw new Error("Failed to get installed apps from simulator");
116 | }
117 | }
118 |
119 | public async captureScreenshot(udid: string): Promise {
120 | try {
121 | // Get temp file path for screenshot
122 | const tempPath = `/tmp/ios-simulator-screenshot-${Date.now()}.png`;
123 |
124 | // Capture screenshot using simctl
125 | await execAsync(`xcrun simctl io ${udid} screenshot "${tempPath}"`);
126 |
127 | // Read the file
128 | const fs = require("fs/promises");
129 | const screenshot = await fs.readFile(tempPath);
130 |
131 | // Clean up temp file
132 | await fs.unlink(tempPath).catch(() => {});
133 |
134 | return screenshot;
135 | } catch (error) {
136 | console.error("Error capturing screenshot:", error);
137 | throw new Error("Failed to capture iOS simulator screenshot");
138 | }
139 | }
140 | }
141 |
--------------------------------------------------------------------------------
/src/ios/services/status-bar.ts:
--------------------------------------------------------------------------------
1 | import * as vscode from "vscode";
2 | import { iOSSimulatorInfo } from "../../shared/types";
3 |
4 | export class iOSStatusBar {
5 | private statusBarItem: vscode.StatusBarItem;
6 | private isConnected: boolean = false;
7 | private activeSimulator: iOSSimulatorInfo | null = null;
8 | private activeAppName: string | null = null;
9 |
10 | constructor() {
11 | this.statusBarItem = vscode.window.createStatusBarItem(
12 | vscode.StatusBarAlignment.Right,
13 | 99
14 | );
15 | this.statusBarItem.command = "web-preview.connectiOSSimulator";
16 | this.update();
17 | }
18 |
19 | public setConnected(connected: boolean): void {
20 | this.isConnected = connected;
21 | this.update();
22 | }
23 |
24 | public setActiveSimulator(simulator: iOSSimulatorInfo | null): void {
25 | this.activeSimulator = simulator;
26 | this.update();
27 | }
28 |
29 | public setActiveApp(appName: string | null): void {
30 | this.activeAppName = appName;
31 | this.update();
32 | }
33 |
34 | public update(): void {
35 | // Get feature toggle manager
36 | const featureToggleManager =
37 | require("../../shared/config/feature-toggles").FeatureToggleManager.getInstance();
38 |
39 | // Only show the status bar if iOS features are enabled
40 | if (!featureToggleManager.isiOSFeaturesEnabled()) {
41 | this.statusBarItem.hide();
42 | return;
43 | }
44 |
45 | if (!this.isConnected) {
46 | this.statusBarItem.text = "$(device-mobile) Connect iOS Simulator";
47 | } else {
48 | this.statusBarItem.text = `$(device-mobile) ${
49 | this.activeSimulator?.name || "iOS Simulator"
50 | }${this.activeAppName ? ` (${this.activeAppName})` : ""}`;
51 | }
52 | this.statusBarItem.tooltip = this.isConnected
53 | ? `Connected to: ${this.activeSimulator?.name} (${
54 | this.activeSimulator?.runtime
55 | })${
56 | this.activeAppName ? `\nMonitoring app: ${this.activeAppName}` : ""
57 | }`
58 | : "Click to connect to an iOS Simulator";
59 | this.statusBarItem.show();
60 |
61 | // Update context flag for menu visibility
62 | vscode.commands.executeCommand(
63 | "setContext",
64 | "web-preview:iOSConnected",
65 | this.isConnected
66 | );
67 | }
68 |
69 | public dispose(): void {
70 | this.statusBarItem.dispose();
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/src/ios/simulator.ts:
--------------------------------------------------------------------------------
1 | import * as vscode from "vscode";
2 | import { EventEmitter } from "events";
3 | import { iOSSimulatorInfo } from "../shared/types";
4 | import { ToastService } from "../shared/utils/toast";
5 | import { ErrorHandler, SimulatorError } from "../shared/utils/error-handler";
6 | import { iOSStatusBar } from "./services/status-bar";
7 | import { SimulatorScanner } from "./services/simulator-scanner";
8 |
9 | interface SimulatorQuickPickItem extends vscode.QuickPickItem {
10 | simulator: iOSSimulatorInfo;
11 | }
12 |
13 | export class iOSSimulatorMonitor extends EventEmitter {
14 | private static instance: iOSSimulatorMonitor;
15 | private activeSimulator: iOSSimulatorInfo | null = null;
16 | private isConnected: boolean = false;
17 | private toastService: ToastService;
18 | private errorHandler: ErrorHandler;
19 | private disconnectEmitter = new vscode.EventEmitter();
20 | private activeAppName: string | null = null;
21 |
22 | // Modularized components
23 | private statusBar: iOSStatusBar;
24 | private simulatorScanner: SimulatorScanner;
25 |
26 | private constructor() {
27 | super();
28 | this.toastService = ToastService.getInstance();
29 | this.errorHandler = ErrorHandler.getInstance();
30 |
31 | // Initialize components
32 | this.statusBar = new iOSStatusBar();
33 | this.simulatorScanner = new SimulatorScanner();
34 | }
35 |
36 | public static getInstance(): iOSSimulatorMonitor {
37 | if (!iOSSimulatorMonitor.instance) {
38 | iOSSimulatorMonitor.instance = new iOSSimulatorMonitor();
39 | }
40 | return iOSSimulatorMonitor.instance;
41 | }
42 |
43 | public updateStatusBar() {
44 | // Delegate to status bar service
45 | this.statusBar.update();
46 | }
47 |
48 | public async connect(): Promise {
49 | try {
50 | if (this.isConnected) {
51 | await this.disconnect();
52 | }
53 |
54 | const simulators = await this.simulatorScanner.getAvailableSimulators();
55 |
56 | if (!simulators.length) {
57 | throw new SimulatorError(
58 | "No iOS simulators found. Please start a simulator first."
59 | );
60 | }
61 |
62 | const selection =
63 | await vscode.window.showQuickPick(
64 | simulators.map((sim: iOSSimulatorInfo) => ({
65 | label: sim.name,
66 | description: `(${sim.runtime})`,
67 | simulator: sim,
68 | })),
69 | { placeHolder: "Select iOS Simulator" }
70 | );
71 |
72 | if (!selection) {
73 | return;
74 | }
75 |
76 | await this.monitorSimulator(selection.simulator);
77 | await this.selectAppToMonitor();
78 | } catch (error) {
79 | this.errorHandler.handleSimulatorError(
80 | error,
81 | "Failed to connect to iOS simulator"
82 | );
83 | this.isConnected = false;
84 | throw error;
85 | }
86 | }
87 |
88 | private async selectAppToMonitor(): Promise {
89 | if (!this.activeSimulator) {
90 | return;
91 | }
92 |
93 | try {
94 | // Show a loading indicator
95 | await this.toastService.showProgress(
96 | "Loading simulator apps...",
97 | async () => {
98 | const apps = await this.simulatorScanner.getInstalledApps(
99 | this.activeSimulator!.udid
100 | );
101 |
102 | if (apps.length === 0) {
103 | this.toastService.showError("No apps found on simulator");
104 | this.disconnect();
105 | return;
106 | }
107 |
108 | // Create quick pick items for each app
109 | const appPicks = apps.map((app) => ({
110 | label: app.name,
111 | description: app.bundleId,
112 | app,
113 | }));
114 |
115 | const selection = await vscode.window.showQuickPick(appPicks, {
116 | placeHolder: "Select an app to monitor logs",
117 | });
118 |
119 | if (!selection) {
120 | // If user cancels, disconnect
121 | this.disconnect();
122 | return;
123 | }
124 |
125 | // Specific app selected
126 | this.activeAppName = selection.app.name;
127 | this.statusBar.setActiveApp(this.activeAppName);
128 |
129 | // Show success notification
130 | await this.showSuccessNotification();
131 | }
132 | );
133 | } catch (error) {
134 | console.error("Error getting installed apps:", error);
135 |
136 | // Show error to user if listapps command failed
137 | if (this.toastService) {
138 | this.toastService.showError(
139 | "Failed to detect installed apps. Please try again or check if the simulator is running properly."
140 | );
141 | }
142 |
143 | // Throw error to be handled by caller
144 | throw new Error("Failed to get installed apps");
145 | }
146 | }
147 |
148 | private async monitorSimulator(simulator: iOSSimulatorInfo) {
149 | try {
150 | // Store the active simulator
151 | this.activeSimulator = simulator;
152 | this.isConnected = true;
153 |
154 | // Update status bar
155 | this.statusBar.setConnected(true);
156 | this.statusBar.setActiveSimulator(simulator);
157 | } catch (error) {
158 | console.error("Error monitoring simulator:", error);
159 | this.toastService.showError("Failed to initialize simulator connection");
160 | await this.disconnect();
161 | }
162 | }
163 |
164 | private async showSuccessNotification() {
165 | if (this.activeSimulator && this.activeAppName) {
166 | this.toastService.showInfo(
167 | `Connected to iOS Simulator: ${this.activeSimulator.name}\nApp: ${this.activeAppName}`
168 | );
169 | }
170 | }
171 |
172 | public async disconnect(): Promise {
173 | try {
174 | this.activeSimulator = null;
175 | this.activeAppName = null;
176 | this.isConnected = false;
177 | this.statusBar.setConnected(false);
178 | this.disconnectEmitter.fire();
179 | } catch (error) {
180 | this.errorHandler.handleSimulatorError(
181 | error,
182 | "Failed to disconnect from iOS simulator"
183 | );
184 | throw error;
185 | }
186 | }
187 |
188 | public getActiveSimulator(): iOSSimulatorInfo | null {
189 | return this.activeSimulator;
190 | }
191 |
192 | public isSimulatorConnected(): boolean {
193 | return this.isConnected && this.activeSimulator !== null;
194 | }
195 |
196 | public dispose() {
197 | this.disconnect();
198 | this.statusBar.dispose();
199 | }
200 |
201 | public async captureScreenshot(): Promise {
202 | try {
203 | if (!this.isSimulatorConnected()) {
204 | throw new SimulatorError("No iOS simulator connected");
205 | }
206 |
207 | if (!this.activeSimulator) {
208 | throw new SimulatorError("No active simulator");
209 | }
210 |
211 | return await this.simulatorScanner.captureScreenshot(
212 | this.activeSimulator.udid
213 | );
214 | } catch (error) {
215 | this.errorHandler.handleSimulatorError(
216 | error,
217 | "Failed to capture iOS screenshot"
218 | );
219 | throw error;
220 | }
221 | }
222 |
223 | public onDisconnect(listener: () => void): vscode.Disposable {
224 | return this.disconnectEmitter.event(listener);
225 | }
226 | }
227 |
--------------------------------------------------------------------------------
/src/shared/composer/integration.ts:
--------------------------------------------------------------------------------
1 | import * as vscode from "vscode";
2 | import * as fs from "fs/promises";
3 | import * as path from "path";
4 | import {
5 | LogData,
6 | BrowserLog,
7 | NetworkRequest,
8 | Exception,
9 | DOMEvent,
10 | } from "../types";
11 | import {
12 | clearClipboard,
13 | copyImageToClipboard,
14 | copyTextToClipboard,
15 | delay,
16 | } from "../utils/clipboard";
17 | import { ToastService } from "../utils/toast";
18 |
19 | export class ComposerIntegration {
20 | private static instance: ComposerIntegration;
21 | private readonly context: vscode.ExtensionContext;
22 | private composerOpened: boolean = false;
23 | private toastService: ToastService;
24 |
25 | private constructor(context: vscode.ExtensionContext) {
26 | this.context = context;
27 | this.toastService = ToastService.getInstance();
28 | }
29 |
30 | public static getInstance(
31 | context: vscode.ExtensionContext
32 | ): ComposerIntegration {
33 | if (!ComposerIntegration.instance) {
34 | ComposerIntegration.instance = new ComposerIntegration(context);
35 | }
36 | return ComposerIntegration.instance;
37 | }
38 |
39 | private async openComposer(): Promise {
40 | if (this.composerOpened) {
41 | return;
42 | }
43 | try {
44 | await vscode.commands.executeCommand("aichat.newchataction");
45 | this.composerOpened = true;
46 | await delay(100);
47 | } catch {
48 | this.toastService.showError(
49 | "Failed to open composer. Please make sure Cursor is installed and configured."
50 | );
51 | return;
52 | }
53 | }
54 |
55 | public async sendToComposer(
56 | screenshot?: Buffer,
57 | logs?: LogData
58 | ): Promise {
59 | const maxRetries = 2;
60 | let imageAttempt = 0;
61 | let textAttempt = 0;
62 | let imageSuccess = false;
63 | let textSuccess = false;
64 |
65 | const formattedLogs = logs ? this.formatLogs(logs) : "";
66 |
67 | while (
68 | (screenshot && !imageSuccess && imageAttempt <= maxRetries) ||
69 | (formattedLogs && !textSuccess && textAttempt <= maxRetries)
70 | ) {
71 | try {
72 | await this.openComposer();
73 |
74 | if (screenshot && !imageSuccess) {
75 | await clearClipboard();
76 | try {
77 | await this.sendImageToComposer(screenshot);
78 | await delay(50);
79 | await vscode.commands.executeCommand(
80 | "editor.action.clipboardPasteAction"
81 | );
82 | imageSuccess = true;
83 | } catch (err) {
84 | if (imageAttempt === maxRetries) {
85 | throw new Error(`Failed to send image: ${String(err)}`);
86 | }
87 | imageAttempt++;
88 | await delay(50);
89 | }
90 | }
91 |
92 | if (formattedLogs && !textSuccess) {
93 | await delay(50);
94 | await clearClipboard();
95 | try {
96 | await this.prepareTextForComposer(formattedLogs);
97 | await delay(50);
98 | await vscode.commands.executeCommand(
99 | "editor.action.clipboardPasteAction"
100 | );
101 | textSuccess = true;
102 | } catch (err) {
103 | if (textAttempt === maxRetries) {
104 | throw new Error(`Failed to send logs: ${String(err)}`);
105 | }
106 | textAttempt++;
107 | await delay(50);
108 | }
109 | }
110 |
111 | if ((!screenshot || imageSuccess) && (!formattedLogs || textSuccess)) {
112 | await this.showSuccessNotification();
113 | return;
114 | }
115 | } catch (error) {
116 | const errorMessage =
117 | error instanceof Error ? error.message : String(error);
118 | this.toastService.showError(
119 | `Failed to send data to composer: ${errorMessage}`
120 | );
121 | throw error;
122 | } finally {
123 | this.composerOpened = false;
124 | }
125 | }
126 | }
127 |
128 | public async sendiOSToComposer(screenshot?: Buffer): Promise {
129 | const maxRetries = 2;
130 | let imageAttempt = 0;
131 | let imageSuccess = false;
132 |
133 | while (screenshot && !imageSuccess && imageAttempt <= maxRetries) {
134 | try {
135 | await this.openComposer();
136 |
137 | if (screenshot && !imageSuccess) {
138 | await clearClipboard();
139 | try {
140 | await this.sendImageToComposer(screenshot);
141 | await delay(50);
142 | await vscode.commands.executeCommand(
143 | "editor.action.clipboardPasteAction"
144 | );
145 | imageSuccess = true;
146 | } catch (err) {
147 | if (imageAttempt === maxRetries) {
148 | throw new Error(`Failed to send image: ${String(err)}`);
149 | }
150 | imageAttempt++;
151 | await delay(50);
152 | }
153 | }
154 |
155 | if (!screenshot || imageSuccess) {
156 | await this.showSuccessNotification();
157 | return;
158 | }
159 | } catch (error) {
160 | const errorMessage =
161 | error instanceof Error ? error.message : String(error);
162 | this.toastService.showError(
163 | `Failed to send iOS data to composer: ${errorMessage}`
164 | );
165 | throw error;
166 | } finally {
167 | this.composerOpened = false;
168 | }
169 | }
170 | }
171 |
172 | private async sendImageToComposer(screenshot: Buffer): Promise {
173 | const manifest = require("../../../package.json");
174 | const extensionId = `${manifest.publisher}.${manifest.name}`;
175 | const tmpDir = this.context.globalStorageUri.fsPath;
176 | let tmpFile: string | undefined;
177 |
178 | try {
179 | const tmpFilePath = path.join(
180 | tmpDir,
181 | `${extensionId}-preview-${Date.now()}.png`
182 | );
183 |
184 | await Promise.all([
185 | fs.mkdir(tmpDir, { recursive: true }),
186 | fs.writeFile(tmpFilePath, screenshot),
187 | ]);
188 | tmpFile = tmpFilePath;
189 |
190 | await copyImageToClipboard(tmpFile);
191 | await delay(50);
192 | } catch (error) {
193 | throw new Error(`Failed to send image: ${error}`);
194 | } finally {
195 | if (tmpFile) {
196 | fs.unlink(tmpFile).catch((error) =>
197 | console.error("Failed to clean up temporary file:", error)
198 | );
199 | }
200 | }
201 | }
202 |
203 | private async prepareTextForComposer(text: string): Promise {
204 | if (!text) {
205 | return;
206 | }
207 |
208 | await copyTextToClipboard(text);
209 | await delay(50);
210 | }
211 |
212 | private async showSuccessNotification() {
213 | await this.toastService.showProgress(
214 | "Successfully sent to Composer",
215 | async () => {
216 | await delay(2000);
217 | }
218 | );
219 | }
220 |
221 | private formatLogs(logs: LogData): string {
222 | let result = "---Console Logs---\n";
223 | logs.console.forEach((log: BrowserLog) => {
224 | const timestamp = new Date(log.timestamp).toLocaleTimeString();
225 | let prefix = "";
226 |
227 | switch (log.type) {
228 | case "error":
229 | prefix = "ERROR";
230 | break;
231 | case "warning":
232 | prefix = "WARN";
233 | break;
234 | case "debug":
235 | prefix = "DEBUG";
236 | break;
237 | case "info":
238 | prefix = "INFO";
239 | break;
240 | default:
241 | prefix = "LOG";
242 | }
243 |
244 | result += `${timestamp} [${prefix}] ${log.message}\n`;
245 | });
246 |
247 | if (logs.network && logs.network.length > 0) {
248 | result += "\n---Network Requests---\n";
249 | logs.network.forEach((req: NetworkRequest) => {
250 | const timestamp = new Date(req.timestamp).toLocaleTimeString();
251 | const duration = req.duration ? `${req.duration}ms` : "pending";
252 | const status = req.status ? `${req.status}` : "N/A";
253 | result += `${timestamp} [${req.method}] ${req.url} - Status: ${status}, Duration: ${duration}\n`;
254 | });
255 | }
256 |
257 | if (logs.exceptions && logs.exceptions.length > 0) {
258 | result += "\n---Exceptions---\n";
259 | logs.exceptions.forEach((ex: Exception) => {
260 | const timestamp = new Date(ex.timestamp).toLocaleTimeString();
261 | result += `${timestamp} - ${ex.message}\n${ex.stack || ""}\n`;
262 | });
263 | }
264 |
265 | if (logs.domEvents && logs.domEvents.length > 0) {
266 | result += "\n---DOM Events---\n";
267 | logs.domEvents.forEach((event: DOMEvent) => {
268 | const timestamp = new Date(event.timestamp).toLocaleTimeString();
269 | result += `${timestamp} - [${event.type}] ${event.target}\n`;
270 | });
271 | }
272 |
273 | return result;
274 | }
275 | }
276 |
--------------------------------------------------------------------------------
/src/shared/config/feature-toggles.ts:
--------------------------------------------------------------------------------
1 | import * as vscode from "vscode";
2 |
3 | export interface FeatureToggles {
4 | iOSFeatures: boolean;
5 | }
6 |
7 | export class FeatureToggleManager {
8 | private static instance: FeatureToggleManager;
9 | private context: vscode.ExtensionContext | undefined;
10 |
11 | private constructor() {}
12 |
13 | public static getInstance(): FeatureToggleManager {
14 | if (!FeatureToggleManager.instance) {
15 | FeatureToggleManager.instance = new FeatureToggleManager();
16 | }
17 | return FeatureToggleManager.instance;
18 | }
19 |
20 | public initialize(context: vscode.ExtensionContext): void {
21 | this.context = context;
22 | }
23 |
24 | public getToggles(): FeatureToggles {
25 | if (!this.context) {
26 | return this.getDefaultToggles();
27 | }
28 |
29 | const toggles =
30 | this.context.globalState.get("featureToggles");
31 | return toggles || this.getDefaultToggles();
32 | }
33 |
34 | public async updateToggles(toggles: FeatureToggles): Promise {
35 | if (!this.context) {
36 | return;
37 | }
38 |
39 | const previousToggles = this.getToggles();
40 | await this.context.globalState.update("featureToggles", toggles);
41 |
42 | // Update context for menu visibility
43 | await vscode.commands.executeCommand(
44 | "setContext",
45 | "web-preview:iOSFeaturesEnabled",
46 | toggles.iOSFeatures
47 | );
48 |
49 | // If iOS features toggle changed, update the simulator status bar
50 | if (previousToggles.iOSFeatures !== toggles.iOSFeatures) {
51 | const iOSSimulatorMonitor =
52 | require("../../ios/simulator").iOSSimulatorMonitor.getInstance();
53 | iOSSimulatorMonitor.updateStatusBar();
54 | }
55 | }
56 |
57 | public getDefaultToggles(): FeatureToggles {
58 | return {
59 | iOSFeatures: false, // iOS features disabled by default
60 | };
61 | }
62 |
63 | public async enableiOSFeatures(enabled: boolean): Promise {
64 | const toggles = this.getToggles();
65 | toggles.iOSFeatures = enabled;
66 | await this.updateToggles(toggles);
67 | }
68 |
69 | public isiOSFeaturesEnabled(): boolean {
70 | return this.getToggles().iOSFeatures;
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/src/shared/config/index.ts:
--------------------------------------------------------------------------------
1 | import * as vscode from "vscode";
2 | import { ExtensionConfig } from "../types";
3 |
4 | export class ConfigManager {
5 | private static instance: ConfigManager;
6 | private config: vscode.WorkspaceConfiguration;
7 |
8 | private constructor() {
9 | this.config = vscode.workspace.getConfiguration("composerWeb");
10 | }
11 |
12 | public static getInstance(): ConfigManager {
13 | if (!ConfigManager.instance) {
14 | ConfigManager.instance = new ConfigManager();
15 | }
16 | return ConfigManager.instance;
17 | }
18 |
19 | public get(key: string & keyof ExtensionConfig): T {
20 | return this.config.get(key) as T;
21 | }
22 |
23 | public async update(
24 | key: string & keyof ExtensionConfig,
25 | value: T
26 | ): Promise {
27 | await this.config.update(key, value, vscode.ConfigurationTarget.Global);
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/src/shared/config/log-filters.ts:
--------------------------------------------------------------------------------
1 | import * as vscode from "vscode";
2 |
3 | export interface LogFilters {
4 | console: {
5 | info: boolean;
6 | warn: boolean;
7 | error: boolean;
8 | debug: boolean;
9 | log: boolean;
10 | };
11 | network: {
12 | enabled: boolean;
13 | errorsOnly: boolean;
14 | };
15 | }
16 |
17 | export class LogFilterManager {
18 | private static instance: LogFilterManager;
19 | private context: vscode.ExtensionContext | undefined;
20 |
21 | private constructor() {}
22 |
23 | public static getInstance(): LogFilterManager {
24 | if (!LogFilterManager.instance) {
25 | LogFilterManager.instance = new LogFilterManager();
26 | }
27 | return LogFilterManager.instance;
28 | }
29 |
30 | public initialize(context: vscode.ExtensionContext): void {
31 | this.context = context;
32 | }
33 |
34 | public getFilters(): LogFilters {
35 | if (!this.context) {
36 | return this.getDefaultFilters();
37 | }
38 |
39 | const filters = this.context.globalState.get("logFilters");
40 | return filters || this.getDefaultFilters();
41 | }
42 |
43 | public async updateFilters(filters: LogFilters): Promise {
44 | if (!this.context) {
45 | return;
46 | }
47 |
48 | await this.context.globalState.update("logFilters", filters);
49 | }
50 |
51 | public getDefaultFilters(): LogFilters {
52 | return {
53 | console: {
54 | info: true,
55 | warn: true,
56 | error: true,
57 | debug: true,
58 | log: true,
59 | },
60 | network: {
61 | enabled: true,
62 | errorsOnly: false,
63 | },
64 | };
65 | }
66 |
67 | public shouldLogConsoleMessage(type: string): boolean {
68 | const filters = this.getFilters();
69 | const normalizedType = type.toLowerCase();
70 |
71 | switch (normalizedType) {
72 | case "info":
73 | return filters.console.info;
74 | case "warning":
75 | case "warn":
76 | return filters.console.warn;
77 | case "error":
78 | return filters.console.error;
79 | case "debug":
80 | return filters.console.debug;
81 | case "log":
82 | default:
83 | return filters.console.log;
84 | }
85 | }
86 |
87 | public shouldLogNetworkRequest(status: number): boolean {
88 | const filters = this.getFilters();
89 |
90 | if (!filters.network.enabled) {
91 | return false;
92 | }
93 |
94 | if (filters.network.errorsOnly) {
95 | return status >= 400;
96 | }
97 |
98 | return true;
99 | }
100 | }
101 |
--------------------------------------------------------------------------------
/src/shared/types/index.ts:
--------------------------------------------------------------------------------
1 | export interface BrowserLog {
2 | type: string;
3 | args: string[];
4 | timestamp: number;
5 | message: string;
6 | }
7 |
8 | export interface NetworkRequest {
9 | url: string;
10 | status: number;
11 | error?: string;
12 | timestamp: number;
13 | method: string;
14 | duration?: number;
15 | }
16 |
17 | export interface DOMEvent {
18 | type: string;
19 | target: string;
20 | timestamp: number;
21 | }
22 |
23 | export interface Exception {
24 | message: string;
25 | stack?: string;
26 | timestamp: number;
27 | }
28 |
29 | export interface MonitoredPage {
30 | title: string;
31 | url: string;
32 | id: string;
33 | }
34 |
35 | export interface iOSSimulatorInfo {
36 | name: string;
37 | udid: string;
38 | status: string;
39 | runtime: string;
40 | }
41 |
42 | export interface iOSApp {
43 | name: string;
44 | bundleId: string;
45 | }
46 |
47 | export interface ExtensionConfig {
48 | captureFormat: "png" | "jpg";
49 | includeStyles: boolean;
50 | quality: number;
51 | remoteDebuggingUrl: string;
52 | customKeybindings?: KeybindConfig[];
53 | }
54 |
55 | export interface KeybindConfig {
56 | command: string;
57 | key: string;
58 | mac?: string;
59 | }
60 |
61 | export interface LogData {
62 | console: BrowserLog[];
63 | network: NetworkRequest[];
64 | exceptions?: Exception[];
65 | domEvents?: DOMEvent[];
66 | }
67 |
--------------------------------------------------------------------------------
/src/shared/utils/clipboard.ts:
--------------------------------------------------------------------------------
1 | import { exec } from "child_process";
2 | import * as vscode from "vscode";
3 | import { ToastService } from "./toast";
4 |
5 | const toastService = ToastService.getInstance();
6 |
7 | export async function clearClipboard(): Promise {
8 | const platform = process.platform;
9 | let command: string | null = null;
10 |
11 | switch (platform) {
12 | case "darwin":
13 | command = "osascript -e \"set the clipboard to \"\"\"";
14 | break;
15 | case "win32":
16 | await vscode.env.clipboard.writeText("");
17 | return;
18 | case "linux":
19 | command = "xclip -selection clipboard -i /dev/null";
20 | break;
21 | }
22 |
23 | if (command) {
24 | await new Promise((resolve, reject) => {
25 | exec(command!, (error) => {
26 | if (error) {
27 | reject(error);
28 | } else {
29 | resolve();
30 | }
31 | });
32 | });
33 | }
34 | }
35 |
36 | export async function copyImageToClipboard(imagePath: string): Promise {
37 | const platform = process.platform;
38 | const command = getClipboardImageCommand(platform, imagePath);
39 |
40 | if (command) {
41 | await new Promise((resolve, reject) => {
42 | exec(command, { timeout: 1000 }, (error: Error | null) => {
43 | if (error) {
44 | toastService.showError(
45 | `Failed to copy image to clipboard: ${error.message}`
46 | );
47 | reject(error);
48 | } else {
49 | resolve();
50 | }
51 | });
52 | });
53 | }
54 | }
55 |
56 | export async function copyTextToClipboard(text: string): Promise {
57 | const platform = process.platform;
58 |
59 | if (platform === "win32") {
60 | await vscode.env.clipboard.writeText(text);
61 | return;
62 | }
63 |
64 | const command = getClipboardTextCommand(platform, text);
65 | if (!command) {
66 | return;
67 | }
68 | await new Promise((resolve, reject) => {
69 | exec(command, { timeout: 500 }, (error: Error | null) => {
70 | if (error) {
71 | toastService.showError(
72 | `Failed to copy text logs to clipboard: ${error.message}`
73 | );
74 | reject(error);
75 | } else {
76 | resolve();
77 | }
78 | });
79 | });
80 | }
81 |
82 | function getClipboardImageCommand(
83 | platform: string,
84 | imagePath: string
85 | ): string | null {
86 | switch (platform) {
87 | case "darwin":
88 | return `osascript -e '
89 | set imageFile to POSIX file "${imagePath}"
90 | set imageData to read imageFile as «class PNGf»
91 | set the clipboard to imageData
92 | '`;
93 | case "win32":
94 | return `powershell -NoProfile -Command "[Reflection.Assembly]::LoadWithPartialName('System.Windows.Forms'); [System.Windows.Forms.Clipboard]::SetImage([System.Drawing.Image]::FromFile('${imagePath.replace(
95 | /'/g,
96 | "''"
97 | )}'))"`;
98 | case "linux":
99 | return `xclip -selection clipboard -t image/png -i "${imagePath}"`;
100 | default:
101 | return null;
102 | }
103 | }
104 |
105 | function getClipboardTextCommand(
106 | platform: string,
107 | text: string
108 | ): string | null {
109 | switch (platform) {
110 | case "darwin":
111 | return `echo "${text.replace(/"/g, "\\\"")}" | pbcopy`;
112 | case "win32":
113 | return `powershell -command "Set-Clipboard -Value \\"${text.replace(
114 | /"/g,
115 | "`\""
116 | )}\\""`;
117 | case "linux":
118 | return `echo "${text.replace(/"/g, "\\\"")}" | xclip -selection clipboard`;
119 | default:
120 | return null;
121 | }
122 | }
123 |
124 | export function delay(ms: number): Promise {
125 | return new Promise((resolve) => setTimeout(resolve, ms));
126 | }
127 |
--------------------------------------------------------------------------------
/src/shared/utils/error-handler.ts:
--------------------------------------------------------------------------------
1 | import { ToastService } from "./toast";
2 |
3 | // Custom error types
4 | export class BrowserError extends Error {
5 | constructor(message: string) {
6 | super(message);
7 | this.name = "BrowserError";
8 | }
9 | }
10 |
11 | export class SimulatorError extends Error {
12 | constructor(message: string) {
13 | super(message);
14 | this.name = "SimulatorError";
15 | }
16 | }
17 |
18 | export class ConfigurationError extends Error {
19 | constructor(message: string) {
20 | super(message);
21 | this.name = "ConfigurationError";
22 | }
23 | }
24 |
25 | export class ComposerError extends Error {
26 | constructor(message: string) {
27 | super(message);
28 | this.name = "ComposerError";
29 | }
30 | }
31 |
32 | export class ErrorHandler {
33 | private static instance: ErrorHandler;
34 | private toastService: ToastService;
35 |
36 | private constructor() {
37 | this.toastService = ToastService.getInstance();
38 | }
39 |
40 | public static getInstance(): ErrorHandler {
41 | if (!ErrorHandler.instance) {
42 | ErrorHandler.instance = new ErrorHandler();
43 | }
44 | return ErrorHandler.instance;
45 | }
46 |
47 | public handleError(
48 | error: unknown,
49 | context: string,
50 | silent: boolean = false
51 | ): void {
52 | const errorMessage = this.formatErrorMessage(error);
53 |
54 | // Log error with context
55 | console.error(`[${context}]`, error);
56 |
57 | // Show error to user unless silent mode is requested
58 | if (!silent) {
59 | this.toastService.showError(`${context}: ${errorMessage}`);
60 | }
61 | }
62 |
63 | public async handleErrorWithProgress(
64 | context: string,
65 | operation: () => Promise,
66 | progressTitle: string
67 | ): Promise {
68 | let result: T;
69 | try {
70 | await this.toastService.showProgress(progressTitle, async () => {
71 | result = await operation();
72 | });
73 | return result!;
74 | } catch (error) {
75 | this.handleError(error, context);
76 | throw error;
77 | }
78 | }
79 |
80 | private formatErrorMessage(error: unknown): string {
81 | if (error instanceof Error) {
82 | return error.message;
83 | }
84 | return String(error);
85 | }
86 |
87 | // Helper methods for common error scenarios
88 | public handleBrowserError(error: unknown, context: string): void {
89 | if (error instanceof BrowserError) {
90 | this.handleError(error, context);
91 | } else {
92 | this.handleError(
93 | new BrowserError(this.formatErrorMessage(error)),
94 | context
95 | );
96 | }
97 | }
98 |
99 | public handleSimulatorError(error: unknown, context: string): void {
100 | if (error instanceof SimulatorError) {
101 | this.handleError(error, context);
102 | } else {
103 | this.handleError(
104 | new SimulatorError(this.formatErrorMessage(error)),
105 | context
106 | );
107 | }
108 | }
109 |
110 | public handleConfigError(error: unknown, context: string): void {
111 | if (error instanceof ConfigurationError) {
112 | this.handleError(error, context);
113 | } else {
114 | this.handleError(
115 | new ConfigurationError(this.formatErrorMessage(error)),
116 | context
117 | );
118 | }
119 | }
120 |
121 | public handleComposerError(error: unknown, context: string): void {
122 | if (error instanceof ComposerError) {
123 | this.handleError(error, context);
124 | } else {
125 | this.handleError(
126 | new ComposerError(this.formatErrorMessage(error)),
127 | context
128 | );
129 | }
130 | }
131 | }
132 |
--------------------------------------------------------------------------------
/src/shared/utils/keybinding-manager.ts:
--------------------------------------------------------------------------------
1 | import * as vscode from "vscode";
2 | import { ConfigManager } from "../config";
3 |
4 | export interface KeybindConfig {
5 | command: string;
6 | key: string;
7 | mac: string;
8 | }
9 |
10 | export class KeybindingManager {
11 | private static instance: KeybindingManager;
12 | private configManager: ConfigManager;
13 | private currentKeybindings: KeybindConfig[] = [];
14 | private customKeybindings: KeybindConfig[] = [];
15 |
16 | public static readonly VIEW_TYPE = "composer-web.settings";
17 |
18 | private constructor() {
19 | this.configManager = ConfigManager.getInstance();
20 | }
21 |
22 | public static getInstance(): KeybindingManager {
23 | if (!KeybindingManager.instance) {
24 | KeybindingManager.instance = new KeybindingManager();
25 | }
26 | return KeybindingManager.instance;
27 | }
28 |
29 | public async loadKeybindings(): Promise {
30 | try {
31 | this.customKeybindings = await this.getCustomKeybindings();
32 | const defaultKeybindings = await this.getDefaultKeybindings();
33 |
34 | // Start with all default keybindings
35 | const mergedKeybindings = [...defaultKeybindings];
36 |
37 | // For each command that has a custom keybinding, replace the default
38 | this.customKeybindings.forEach((customKb) => {
39 | const index = mergedKeybindings.findIndex(
40 | (kb) => kb.command === customKb.command
41 | );
42 | if (index !== -1) {
43 | mergedKeybindings[index] = customKb;
44 | } else {
45 | mergedKeybindings.push(customKb);
46 | }
47 | });
48 |
49 | this.currentKeybindings = mergedKeybindings;
50 | return this.currentKeybindings;
51 | } catch (error) {
52 | console.error("Failed to load keybindings:", error);
53 | throw error;
54 | }
55 | }
56 |
57 | public async updateKeybinding(
58 | command: string,
59 | key: string,
60 | mac: string
61 | ): Promise {
62 | try {
63 | // If we don't have keybindings loaded yet, load them first
64 | if (this.currentKeybindings.length === 0) {
65 | await this.loadKeybindings();
66 | }
67 |
68 | // Check if this command already has a custom keybinding
69 | const existingIndex = this.customKeybindings.findIndex(
70 | (kb) => kb.command === command
71 | );
72 |
73 | if (existingIndex !== -1) {
74 | // Update existing custom keybinding
75 | this.customKeybindings[existingIndex] = { command, key, mac };
76 | } else {
77 | // Add a new custom keybinding
78 | this.customKeybindings.push({ command, key, mac });
79 | }
80 |
81 | // Save the updated custom keybindings
82 | await this.configManager.update(
83 | "customKeybindings",
84 | this.customKeybindings
85 | );
86 |
87 | // Update the current keybindings in memory
88 | const commandIndex = this.currentKeybindings.findIndex(
89 | (kb) => kb.command === command
90 | );
91 | if (commandIndex !== -1) {
92 | this.currentKeybindings[commandIndex] = { command, key, mac };
93 | }
94 |
95 | // Apply all custom keybindings to VS Code to ensure consistency
96 | await this.applyKeybindingsToVSCode();
97 |
98 | return this.currentKeybindings;
99 | } catch (error) {
100 | console.error("Failed to update keybinding:", error);
101 | throw error;
102 | }
103 | }
104 |
105 | /**
106 | * Reset keybindings to default
107 | */
108 | public async resetToDefault(): Promise {
109 | try {
110 | const defaultKeybindings = await this.getDefaultKeybindings();
111 | this.customKeybindings = [];
112 | await this.configManager.update("customKeybindings", []);
113 | this.currentKeybindings = defaultKeybindings;
114 |
115 | await this.applyKeybindingsToVSCode(true);
116 |
117 | return defaultKeybindings;
118 | } catch (error) {
119 | console.error("Failed to reset keybindings:", error);
120 | throw error;
121 | }
122 | }
123 |
124 | /**
125 | * Get the current keybindings from the package.json
126 | */
127 | public async getDefaultKeybindings(): Promise {
128 | const packageJson = await this.getPackageJson();
129 | return packageJson.contributes.keybindings;
130 | }
131 |
132 | /**
133 | * Get custom keybindings from user settings
134 | */
135 | public async getCustomKeybindings(): Promise {
136 | try {
137 | const customKeybindings = this.configManager.get<
138 | KeybindConfig[] | undefined
139 | >("customKeybindings");
140 | return customKeybindings || [];
141 | } catch (error) {
142 | console.error("Failed to get custom keybindings:", error);
143 | return [];
144 | }
145 | }
146 |
147 | private async applyKeybindingsToVSCode(
148 | isReset: boolean = false
149 | ): Promise {
150 | try {
151 | const defaultKeybindings = await this.getDefaultKeybindings();
152 |
153 | const customizedCommands = new Set(
154 | this.customKeybindings.map((kb) => kb.command)
155 | );
156 |
157 | const keybindingEntries: Array<{
158 | key: string;
159 | command: string;
160 | when?: string;
161 | }> = [];
162 |
163 | if (!isReset) {
164 | // Only add these entries if we're not resetting to defaults
165 |
166 | // 1. For each default keybinding command that has a custom override,
167 | // add a negative keybinding entry to explicitly unset it
168 | defaultKeybindings.forEach((kb) => {
169 | if (customizedCommands.has(kb.command)) {
170 | // Disable default keybinding by adding a negative keybinding entry
171 | const defaultKey = process.platform === "darwin" ? kb.mac : kb.key;
172 | if (defaultKey) {
173 | // Only if there's a key for this platform
174 | keybindingEntries.push({
175 | key: defaultKey,
176 | command: `-${kb.command}`, // Negative command disables it
177 | });
178 | }
179 | }
180 | });
181 |
182 | // 2. Add all custom keybindings
183 | this.customKeybindings.forEach((kb) => {
184 | const customKey = process.platform === "darwin" ? kb.mac : kb.key;
185 | if (customKey) {
186 | keybindingEntries.push({
187 | key: customKey,
188 | command: kb.command,
189 | when: "composerFocused || focusedView =~ /^workbench.panel.aichat.view/",
190 | });
191 | }
192 | });
193 | }
194 |
195 | // 3. Add default keybindings for commands that don't have custom overrides
196 | // If resetting, add ALL default keybindings
197 | defaultKeybindings.forEach((kb) => {
198 | if (isReset || !customizedCommands.has(kb.command)) {
199 | const defaultKey = process.platform === "darwin" ? kb.mac : kb.key;
200 | if (defaultKey) {
201 | keybindingEntries.push({
202 | key: defaultKey,
203 | command: kb.command,
204 | when: "composerFocused || focusedView =~ /^workbench.panel.aichat.view/",
205 | });
206 | }
207 | }
208 | });
209 |
210 | await vscode.commands.executeCommand(
211 | "workbench.action.openGlobalKeybindingsFile"
212 | );
213 |
214 | const editor = vscode.window.activeTextEditor;
215 | if (!editor) {
216 | return;
217 | }
218 |
219 | const document = editor.document;
220 | const text = document.getText();
221 | let existingKeybindings: any[] = [];
222 | try {
223 | existingKeybindings = JSON.parse(text);
224 | } catch (e) {
225 | existingKeybindings = [];
226 | }
227 |
228 | // Remove any existing Composer Web keybindings and their negative counterparts
229 | const filteredKeybindings = existingKeybindings.filter(
230 | (kb) =>
231 | !kb.command?.startsWith("web-preview.") &&
232 | !kb.command?.startsWith("-web-preview.") &&
233 | !kb.command?.startsWith("composer-web.") &&
234 | !kb.command?.startsWith("-composer-web.")
235 | );
236 |
237 | // Add our prepared keybinding entries
238 | const newKeybindings = [...filteredKeybindings, ...keybindingEntries];
239 |
240 | await editor.edit((editBuilder) => {
241 | const fullRange = new vscode.Range(
242 | document.positionAt(0),
243 | document.positionAt(text.length)
244 | );
245 | editBuilder.replace(fullRange, JSON.stringify(newKeybindings, null, 4));
246 | });
247 |
248 | await document.save();
249 | await vscode.commands.executeCommand(
250 | "workbench.action.closeActiveEditor"
251 | );
252 | } catch (error) {
253 | console.error("Failed to apply keybindings to VS Code:", error);
254 | throw error;
255 | }
256 | }
257 |
258 | private async getPackageJson(): Promise {
259 | try {
260 | return require("../../../package.json");
261 | } catch (error) {
262 | console.error("Failed to load package.json:", error);
263 | throw error;
264 | }
265 | }
266 | }
267 |
--------------------------------------------------------------------------------
/src/shared/utils/settings.ts:
--------------------------------------------------------------------------------
1 | import * as vscode from "vscode";
2 | import * as fs from "fs";
3 | import * as path from "path";
4 | import { ToastService } from "./toast";
5 | import { ConfigManager } from "../config";
6 | import { ErrorHandler, ConfigurationError } from "./error-handler";
7 | import { KeybindConfig } from "../types";
8 |
9 | export class SettingsService {
10 | private static instance: SettingsService;
11 | private toastService: ToastService;
12 | private configManager: ConfigManager;
13 | private errorHandler: ErrorHandler;
14 |
15 | private constructor() {
16 | this.toastService = ToastService.getInstance();
17 | this.configManager = ConfigManager.getInstance();
18 | this.errorHandler = ErrorHandler.getInstance();
19 | }
20 |
21 | public static getInstance(): SettingsService {
22 | if (!SettingsService.instance) {
23 | SettingsService.instance = new SettingsService();
24 | }
25 | return SettingsService.instance;
26 | }
27 |
28 | public getKeybinding(command: string): string {
29 | const customKeybindings =
30 | this.configManager.get("customKeybindings") || [];
31 | const binding = customKeybindings.find(
32 | (kb: KeybindConfig) => kb.command === command
33 | );
34 | return binding?.key || "";
35 | }
36 |
37 | public async updateKeybinding(
38 | command: string,
39 | newKeybinding: string
40 | ): Promise {
41 | try {
42 | await this.updateVSCodeKeybindings(command, newKeybinding);
43 | await vscode.commands.executeCommand(
44 | "workbench.action.openGlobalKeybindings"
45 | );
46 | await vscode.commands.executeCommand(
47 | "workbench.action.closeActiveEditor"
48 | );
49 |
50 | this.toastService.showInfo("Keybinding updated successfully");
51 | } catch (error) {
52 | this.errorHandler.handleConfigError(
53 | error,
54 | "Failed to update keybindings"
55 | );
56 | throw new ConfigurationError(
57 | `Failed to update keybindings: ${
58 | error instanceof Error ? error.message : String(error)
59 | }`
60 | );
61 | }
62 | }
63 |
64 | private async updateVSCodeKeybindings(
65 | command: string,
66 | newKeybinding: string
67 | ): Promise {
68 | const keybindingsPath = this.getKeybindingsPath();
69 |
70 | try {
71 | if (!fs.existsSync(keybindingsPath)) {
72 | fs.writeFileSync(keybindingsPath, "[]", "utf8");
73 | }
74 |
75 | let keybindings: KeybindConfig[] = [];
76 | try {
77 | const content = fs.readFileSync(keybindingsPath, "utf8");
78 | keybindings = JSON.parse(content);
79 | } catch (error) {
80 | this.errorHandler.handleError(
81 | error,
82 | "Failed to parse keybindings.json",
83 | true
84 | );
85 | keybindings = [];
86 | }
87 |
88 | keybindings = keybindings.filter(
89 | (kb: KeybindConfig) => kb.command !== command
90 | );
91 |
92 | const isMac = process.platform === "darwin";
93 | const newBinding: KeybindConfig = {
94 | command: command,
95 | key: isMac ? newKeybinding : newKeybinding.replace(/cmd/g, "ctrl"),
96 | };
97 |
98 | if (isMac) {
99 | newBinding.mac = newKeybinding;
100 | }
101 |
102 | keybindings.push(newBinding);
103 |
104 | fs.writeFileSync(
105 | keybindingsPath,
106 | JSON.stringify(keybindings, null, 4),
107 | "utf8"
108 | );
109 | } catch (error) {
110 | this.errorHandler.handleConfigError(
111 | error,
112 | "Failed to update keybindings.json"
113 | );
114 | throw error;
115 | }
116 | }
117 |
118 | private getKeybindingsPath(): string {
119 | const isWindows = process.platform === "win32";
120 | const isMac = process.platform === "darwin";
121 | const homeDir = process.env.HOME || process.env.USERPROFILE;
122 |
123 | if (!homeDir) {
124 | throw new ConfigurationError("Unable to determine user home directory");
125 | }
126 |
127 | if (isWindows) {
128 | return path.join(
129 | process.env.APPDATA || "",
130 | "Code",
131 | "User",
132 | "keybindings.json"
133 | );
134 | } else if (isMac) {
135 | return path.join(
136 | homeDir,
137 | "Library",
138 | "Application Support",
139 | "Code",
140 | "User",
141 | "keybindings.json"
142 | );
143 | } else {
144 | return path.join(homeDir, ".config", "Code", "User", "keybindings.json");
145 | }
146 | }
147 | }
148 |
--------------------------------------------------------------------------------
/src/shared/utils/toast.ts:
--------------------------------------------------------------------------------
1 | import * as vscode from "vscode";
2 |
3 | export class ToastService {
4 | private static instance: ToastService;
5 |
6 | private constructor() {}
7 |
8 | public static getInstance(): ToastService {
9 | if (!ToastService.instance) {
10 | ToastService.instance = new ToastService();
11 | }
12 | return ToastService.instance;
13 | }
14 |
15 | public showError(message: string) {
16 | vscode.window.showErrorMessage(message);
17 | }
18 |
19 | public showWarning(message: string) {
20 | vscode.window.showWarningMessage(message);
21 | }
22 |
23 | public showInfo(message: string) {
24 | vscode.window.showInformationMessage(message);
25 | }
26 |
27 | public async showConfirmation(message: string): Promise {
28 | const result = await vscode.window.showWarningMessage(message, "Yes", "No");
29 | return result === "Yes";
30 | }
31 |
32 | public async showProgress(
33 | title: string,
34 | task: () => Promise,
35 | cancellable: boolean = false
36 | ) {
37 | return vscode.window.withProgress(
38 | {
39 | location: vscode.ProgressLocation.Notification,
40 | title,
41 | cancellable,
42 | },
43 | async (progress) => {
44 | try {
45 | await task();
46 | progress.report({ increment: 100 });
47 | } catch (error) {
48 | const msg = error instanceof Error ? error.message : String(error);
49 | this.showError(msg);
50 | throw error;
51 | }
52 | }
53 | );
54 | }
55 |
56 | // Common toast messages
57 | public showBrowserDisconnected() {
58 | this.showWarning("Browser tab disconnected");
59 | }
60 |
61 | public showiOSSimulatorDisconnected() {
62 | this.showWarning("iOS simulator disconnected");
63 | }
64 |
65 | public showNoTabConnected() {
66 | this.showError("No browser tab connected. Please connect a tab first.");
67 | }
68 |
69 | public showTabClosed() {
70 | this.showWarning("The monitored tab was closed. Monitoring has stopped.");
71 | }
72 |
73 | public showSessionDisconnected() {
74 | this.showError(
75 | "Browser session disconnected. Please reconnect to continue."
76 | );
77 | }
78 |
79 | public showConnectionSuccess() {
80 | this.showInfo("Successfully connected to browser tab");
81 | }
82 |
83 | public showLogsClearedSuccess() {
84 | this.showInfo("Browser logs cleared successfully");
85 | }
86 |
87 | public showLogsSentSuccess() {
88 | this.showInfo("Logs sent to Composer successfully");
89 | }
90 |
91 | public showScreenshotSentSuccess() {
92 | this.showInfo("Screenshot sent to Composer successfully");
93 | }
94 |
95 | public showPageClosedOrCrashed() {
96 | this.showError(
97 | "Browser page was closed or crashed. Please reconnect to continue monitoring."
98 | );
99 | }
100 | }
101 |
--------------------------------------------------------------------------------
/src/views/settings-panel.ts:
--------------------------------------------------------------------------------
1 | import * as vscode from "vscode";
2 | import { getSettingsPanelHtml } from "./templates/settings-panel.template";
3 | import { KeybindingManager } from "../shared/utils/keybinding-manager";
4 | import { LogFilterManager, LogFilters } from "../shared/config/log-filters";
5 | import {
6 | FeatureToggleManager,
7 | FeatureToggles,
8 | } from "../shared/config/feature-toggles";
9 |
10 | export class SettingsPanel implements vscode.WebviewViewProvider {
11 | public static readonly viewType = "composer-web.settings";
12 | private static instance: SettingsPanel | undefined;
13 | private view: vscode.WebviewView | undefined;
14 | private disposables: vscode.Disposable[] = [];
15 | private keybindingManager: KeybindingManager;
16 | private logFilterManager: LogFilterManager;
17 | private featureToggleManager: FeatureToggleManager;
18 |
19 | private constructor() {
20 | this.keybindingManager = KeybindingManager.getInstance();
21 | this.logFilterManager = LogFilterManager.getInstance();
22 | this.featureToggleManager = FeatureToggleManager.getInstance();
23 | }
24 |
25 | public static getInstance(): SettingsPanel {
26 | if (!SettingsPanel.instance) {
27 | SettingsPanel.instance = new SettingsPanel();
28 | }
29 | return SettingsPanel.instance;
30 | }
31 |
32 | public static show(): void {
33 | vscode.commands.executeCommand("workbench.view.extension.composer-web");
34 | }
35 |
36 | public resolveWebviewView(webviewView: vscode.WebviewView) {
37 | this.view = webviewView;
38 |
39 | webviewView.webview.options = {
40 | enableScripts: true,
41 | localResourceRoots: [],
42 | };
43 |
44 | webviewView.webview.html = getSettingsPanelHtml();
45 |
46 | this.disposables.push(
47 | webviewView.webview.onDidReceiveMessage(async (message) => {
48 | switch (message.command) {
49 | case "getKeybindings":
50 | const keybindings = await this.keybindingManager.loadKeybindings();
51 | webviewView.webview.postMessage({
52 | command: "updateKeybindings",
53 | keybindings,
54 | });
55 | break;
56 |
57 | case "getLogFilters":
58 | const filters = this.logFilterManager.getFilters();
59 | webviewView.webview.postMessage({
60 | command: "updateLogFilters",
61 | filters,
62 | });
63 | break;
64 |
65 | case "getFeatureToggles":
66 | const toggles = this.featureToggleManager.getToggles();
67 | webviewView.webview.postMessage({
68 | command: "updateFeatureToggles",
69 | toggles,
70 | });
71 | break;
72 |
73 | case "updateKeybinding":
74 | await this.updateKeybinding(
75 | message.data.command,
76 | message.data.key,
77 | message.data.mac
78 | );
79 | break;
80 |
81 | case "updateLogFilters":
82 | await this.updateLogFilters(message.filters);
83 | break;
84 |
85 | case "updateFeatureToggles":
86 | await this.updateFeatureToggles(message.toggles);
87 | break;
88 |
89 | case "resetToDefault":
90 | await this.resetToDefault();
91 | break;
92 | }
93 | })
94 | );
95 | }
96 |
97 | private async updateKeybinding(
98 | command: string,
99 | key: string,
100 | mac: string
101 | ): Promise {
102 | try {
103 | console.log("Updating keybinding:", { command, key, mac }); // Debug log
104 |
105 | // Check if this keybinding is already assigned to a different command
106 | const keybindings = await this.keybindingManager.loadKeybindings();
107 |
108 | // Check for conflicts with the Windows/Linux keybinding
109 | if (key) {
110 | const conflictingCommand = keybindings.find(
111 | (k) => k.command !== command && k.key === key
112 | );
113 |
114 | if (conflictingCommand) {
115 | this.showNotification(
116 | "warning",
117 | `This keybinding is already assigned to "${conflictingCommand.command}"`
118 | );
119 | return;
120 | }
121 | }
122 |
123 | // Check for conflicts with the Mac keybinding
124 | if (mac) {
125 | const conflictingCommand = keybindings.find(
126 | (k) => k.command !== command && k.mac === mac
127 | );
128 |
129 | if (conflictingCommand) {
130 | this.showNotification(
131 | "warning",
132 | `This keybinding is already assigned to "${conflictingCommand.command}"`
133 | );
134 | return;
135 | }
136 | }
137 |
138 | await this.keybindingManager.updateKeybinding(command, key, mac);
139 | this.showNotification("info", "Keybinding updated successfully");
140 | } catch (error) {
141 | console.error("Error updating keybinding:", error);
142 | this.showNotification(
143 | "error",
144 | "Failed to update keybinding. Please try again."
145 | );
146 | }
147 | }
148 |
149 | private async updateLogFilters(filters: LogFilters): Promise {
150 | try {
151 | await this.logFilterManager.updateFilters(filters);
152 | this.showNotification("info", "Log filters updated successfully");
153 | } catch (error) {
154 | console.error("Error updating log filters:", error);
155 | this.showNotification(
156 | "error",
157 | "Failed to update log filters. Please try again."
158 | );
159 | }
160 | }
161 |
162 | private async updateFeatureToggles(toggles: FeatureToggles): Promise {
163 | try {
164 | await this.featureToggleManager.updateToggles(toggles);
165 | this.showNotification("info", "Feature toggles updated successfully");
166 | } catch (error) {
167 | console.error("Error updating feature toggles:", error);
168 | this.showNotification(
169 | "error",
170 | "Failed to update feature toggles. Please try again."
171 | );
172 | }
173 | }
174 |
175 | private async resetToDefault(): Promise {
176 | try {
177 | await this.keybindingManager.resetToDefault();
178 | await this.logFilterManager.updateFilters(
179 | this.logFilterManager.getDefaultFilters()
180 | );
181 | await this.featureToggleManager.updateToggles(
182 | this.featureToggleManager.getDefaultToggles()
183 | );
184 |
185 | const keybindings = await this.keybindingManager.loadKeybindings();
186 | const filters = this.logFilterManager.getFilters();
187 | const toggles = this.featureToggleManager.getToggles();
188 |
189 | if (this.view) {
190 | this.view.webview.postMessage({
191 | command: "updateKeybindings",
192 | keybindings,
193 | });
194 | this.view.webview.postMessage({
195 | command: "updateLogFilters",
196 | filters,
197 | });
198 | this.view.webview.postMessage({
199 | command: "updateFeatureToggles",
200 | toggles,
201 | });
202 | }
203 |
204 | this.showNotification("info", "Settings reset to default");
205 | } catch (error) {
206 | console.error("Error resetting to default:", error);
207 | this.showNotification(
208 | "error",
209 | "Failed to reset settings. Please try again."
210 | );
211 | }
212 | }
213 |
214 | private showNotification(type: string, message: string): void {
215 | if (this.view) {
216 | this.view.webview.postMessage({
217 | command: "showNotification",
218 | type,
219 | message,
220 | });
221 | }
222 | }
223 |
224 | public dispose() {
225 | while (this.disposables.length) {
226 | const x = this.disposables.pop();
227 | if (x) {
228 | x.dispose();
229 | }
230 | }
231 | }
232 | }
233 |
--------------------------------------------------------------------------------
/src/views/templates/settings-panel.template.ts:
--------------------------------------------------------------------------------
1 | export function getSettingsPanelHtml(): string {
2 | const isMac = process.platform === "darwin";
3 |
4 | return `
5 |
6 |
7 |
8 |
9 | Settings
10 |
414 |
415 |
416 | Settings
417 |
418 |
419 |
420 |
421 |
Log Filtering
422 |
423 |
424 |
Console Logs
425 |
Select which console log types to collect and send
426 |
448 |
449 |
450 |
451 |
Network Requests
452 |
Configure network request logging
453 |
463 |
464 |
465 |
466 |
467 |
468 |
iOS Features
469 |
Enable iOS simulator integration features
470 |
471 |
472 |
473 | iOS Simulator Integration Beta
474 |
475 |
476 |
477 |
478 |
479 |
480 |
You can use the following modifiers: ${
481 | isMac ? "Command (⌘), Control, Option, Shift" : "Ctrl, Alt, Shift"
482 | }. Example combinations include ${
483 | isMac ? "⌘+S, ⌘+Shift+F" : "Ctrl+S, Alt+Shift+F"
484 | }, etc.
485 |
486 |
487 |
488 |
Loading keybindings...
489 |
490 |
491 |
494 |
495 |
913 |
914 | `;
915 | }
916 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "module": "commonjs",
4 | "target": "ES2020",
5 | "lib": ["ES2020"],
6 | "sourceMap": true,
7 | "rootDir": "src",
8 | "strict": true,
9 | "esModuleInterop": true,
10 | "skipLibCheck": true,
11 | "forceConsistentCasingInFileNames": true,
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "noImplicitAny": true,
15 | "noImplicitThis": true,
16 | "noUnusedLocals": true,
17 | "noUnusedParameters": true,
18 | "noFallthroughCasesInSwitch": true,
19 | "outDir": "out"
20 | },
21 | "include": ["src"],
22 | "exclude": ["node_modules", ".vscode-test"]
23 | }
24 |
--------------------------------------------------------------------------------