├── .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 | Composer Web Logo 3 | Composer Web Extension 4 |

5 | 6 | ![Demo](assets/demo.gif) 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 |
427 |
428 | 429 | 430 |
431 |
432 | 433 | 434 |
435 |
436 | 437 | 438 |
439 |
440 | 441 | 442 |
443 |
444 | 445 | 446 |
447 |
448 |
449 | 450 |
451 |

Network Requests

452 |
Configure network request logging
453 |
454 |
455 | 456 | 457 |
458 |
459 | 460 | 461 |
462 |
463 |
464 |
465 | 466 |
467 |
468 |

iOS Features

469 |
Enable iOS simulator integration features
470 |
471 |
472 | 473 | 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 | --------------------------------------------------------------------------------