mirror of
https://github.com/gustavosett/Windows-11-Clipboard-History-For-Linux
synced 2026-04-25 17:15:35 +02:00
feat: start repository
This commit is contained in:
179
.github/CONTRIBUTING.md
vendored
Normal file
179
.github/CONTRIBUTING.md
vendored
Normal file
@@ -0,0 +1,179 @@
|
||||
# Contributing to Win11 Clipboard History
|
||||
|
||||
First off, thank you for considering contributing to Win11 Clipboard History! 🎉
|
||||
|
||||
## 📋 Table of Contents
|
||||
|
||||
- [Code of Conduct](#code-of-conduct)
|
||||
- [Getting Started](#getting-started)
|
||||
- [Development Setup](#development-setup)
|
||||
- [Making Changes](#making-changes)
|
||||
- [Pull Request Process](#pull-request-process)
|
||||
- [Style Guidelines](#style-guidelines)
|
||||
- [Reporting Bugs](#reporting-bugs)
|
||||
- [Suggesting Features](#suggesting-features)
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
This project and everyone participating in it is governed by our commitment to creating a welcoming and inclusive environment. Please be respectful and constructive in all interactions.
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. **Fork the repository** on GitHub
|
||||
2. **Clone your fork** locally:
|
||||
```bash
|
||||
git clone https://github.com/YOUR-USERNAME/win11-clipboard-history-for-linux.git
|
||||
cd win11-clipboard-history-for-linux
|
||||
```
|
||||
3. **Add the upstream remote**:
|
||||
```bash
|
||||
git remote add upstream https://github.com/gustavosett/Windows-11-Clipboard-History-For-Linux.git
|
||||
```
|
||||
|
||||
## Development Setup
|
||||
|
||||
### Prerequisites
|
||||
|
||||
Make sure you have the required dependencies installed:
|
||||
|
||||
```bash
|
||||
# Install system dependencies (auto-detects your distro)
|
||||
make deps
|
||||
|
||||
# Install Rust and Node.js if needed
|
||||
make rust
|
||||
make node
|
||||
source ~/.cargo/env
|
||||
|
||||
# Verify everything is installed
|
||||
make check-deps
|
||||
```
|
||||
|
||||
### Running in Development Mode
|
||||
|
||||
```bash
|
||||
# Install npm dependencies
|
||||
npm install
|
||||
|
||||
# Start development server with hot reload
|
||||
make dev
|
||||
```
|
||||
|
||||
## Making Changes
|
||||
|
||||
1. **Create a new branch** from `master`:
|
||||
```bash
|
||||
git checkout -b feature/your-feature-name
|
||||
# or
|
||||
git checkout -b fix/your-bug-fix
|
||||
```
|
||||
|
||||
2. **Make your changes** and test them locally
|
||||
|
||||
3. **Run linters and formatters**:
|
||||
```bash
|
||||
make lint
|
||||
make format
|
||||
```
|
||||
|
||||
4. **Commit your changes** with a descriptive message:
|
||||
```bash
|
||||
git commit -m "feat: add amazing new feature"
|
||||
# or
|
||||
git commit -m "fix: resolve clipboard paste issue on Wayland"
|
||||
```
|
||||
|
||||
### Commit Message Convention
|
||||
|
||||
We follow [Conventional Commits](https://www.conventionalcommits.org/):
|
||||
|
||||
- `feat:` - New feature
|
||||
- `fix:` - Bug fix
|
||||
- `docs:` - Documentation changes
|
||||
- `style:` - Code style changes (formatting, etc.)
|
||||
- `refactor:` - Code refactoring
|
||||
- `perf:` - Performance improvements
|
||||
- `test:` - Adding or updating tests
|
||||
- `chore:` - Maintenance tasks
|
||||
|
||||
## Pull Request Process
|
||||
|
||||
1. **Update your branch** with the latest upstream changes:
|
||||
```bash
|
||||
git fetch upstream
|
||||
git rebase upstream/master
|
||||
```
|
||||
|
||||
2. **Push your branch** to your fork:
|
||||
```bash
|
||||
git push origin feature/your-feature-name
|
||||
```
|
||||
|
||||
3. **Create a Pull Request** on GitHub
|
||||
|
||||
4. **Fill out the PR template** completely
|
||||
|
||||
5. **Wait for review** - maintainers will review your PR and may request changes
|
||||
|
||||
### PR Requirements
|
||||
|
||||
- [ ] Code follows the project's style guidelines
|
||||
- [ ] All linters pass (`make lint`)
|
||||
- [ ] Changes are tested locally
|
||||
- [ ] Documentation is updated if needed
|
||||
- [ ] PR description clearly explains the changes
|
||||
|
||||
## Style Guidelines
|
||||
|
||||
### TypeScript/React
|
||||
|
||||
- Use functional components with hooks
|
||||
- Follow existing component patterns
|
||||
- Use TypeScript types (avoid `any`)
|
||||
- Use meaningful variable and function names
|
||||
|
||||
### Rust
|
||||
|
||||
- Follow Rust idioms and best practices
|
||||
- Use `cargo fmt` for formatting
|
||||
- Address all `clippy` warnings
|
||||
- Document public functions and modules
|
||||
|
||||
### CSS/Tailwind
|
||||
|
||||
- Use Tailwind utility classes
|
||||
- Follow the Windows 11 design system defined in `tailwind.config.js`
|
||||
- Support both light and dark modes
|
||||
|
||||
## Reporting Bugs
|
||||
|
||||
Before reporting a bug:
|
||||
|
||||
1. **Search existing issues** to avoid duplicates
|
||||
2. **Try the latest version** - the bug might be fixed
|
||||
3. **Collect information**:
|
||||
- OS and version
|
||||
- Desktop environment
|
||||
- Display server (X11/Wayland)
|
||||
- Steps to reproduce
|
||||
- Error messages/logs
|
||||
|
||||
Then [create a bug report](https://github.com/gustavosett/Windows-11-Clipboard-History-For-Linux/issues/new?template=bug_report.md).
|
||||
|
||||
## Suggesting Features
|
||||
|
||||
We welcome feature suggestions! Before suggesting:
|
||||
|
||||
1. **Check if it aligns** with the project's goal (Windows 11-style clipboard manager)
|
||||
2. **Search existing issues** to avoid duplicates
|
||||
3. **Consider implementation** - how complex would it be?
|
||||
|
||||
Then [create a feature request](https://github.com/gustavosett/Windows-11-Clipboard-History-For-Linux/issues/new?template=feature_request.md).
|
||||
|
||||
## Questions?
|
||||
|
||||
Feel free to [open a discussion](https://github.com/gustavosett/Windows-11-Clipboard-History-For-Linux/discussions) for questions or ideas.
|
||||
|
||||
---
|
||||
|
||||
Thank you for contributing! 🙏
|
||||
5
.github/FUNDING.yml
vendored
Normal file
5
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
# Funding
|
||||
|
||||
If you find Win11 Clipboard History useful, consider supporting its development:
|
||||
|
||||
github: [gustavosett]
|
||||
53
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
53
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,53 @@
|
||||
---
|
||||
name: Bug Report
|
||||
about: Report a bug or issue with Win11 Clipboard History
|
||||
title: '[BUG] '
|
||||
labels: ['bug', 'triage']
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
## 🐛 Bug Description
|
||||
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
## 📋 Steps to Reproduce
|
||||
|
||||
1. Go to '...'
|
||||
2. Click on '...'
|
||||
3. Press '...'
|
||||
4. See error
|
||||
|
||||
## ✅ Expected Behavior
|
||||
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
## ❌ Actual Behavior
|
||||
|
||||
What actually happened instead.
|
||||
|
||||
## 📸 Screenshots
|
||||
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
## 🖥️ Environment
|
||||
|
||||
- **OS**: [e.g., Ubuntu 22.04, Fedora 39, Arch Linux]
|
||||
- **Desktop Environment**: [e.g., GNOME 45, KDE Plasma 5.27]
|
||||
- **Display Server**: [X11 / Wayland]
|
||||
- **App Version**: [e.g., 1.0.0]
|
||||
- **Installation Method**: [DEB, RPM, AppImage, Built from source]
|
||||
|
||||
## 📝 Additional Context
|
||||
|
||||
Add any other context about the problem here.
|
||||
|
||||
## 📄 Logs
|
||||
|
||||
<details>
|
||||
<summary>Click to expand logs</summary>
|
||||
|
||||
```
|
||||
Paste any relevant logs here
|
||||
```
|
||||
|
||||
</details>
|
||||
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: 📖 Documentation
|
||||
url: https://github.com/gustavosett/Windows-11-Clipboard-History-For-Linux#readme
|
||||
about: Read the README for installation and usage instructions
|
||||
- name: 💬 Discussions
|
||||
url: https://github.com/gustavosett/Windows-11-Clipboard-History-For-Linux/discussions
|
||||
about: Ask questions and share ideas in GitHub Discussions
|
||||
37
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
37
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
---
|
||||
name: Feature Request
|
||||
about: Suggest a new feature or enhancement
|
||||
title: '[FEATURE] '
|
||||
labels: ['enhancement']
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
## 🚀 Feature Description
|
||||
|
||||
A clear and concise description of the feature you'd like to see.
|
||||
|
||||
## 💡 Motivation
|
||||
|
||||
Why would this feature be useful? What problem does it solve?
|
||||
|
||||
## 📝 Proposed Solution
|
||||
|
||||
Describe how you envision this feature working.
|
||||
|
||||
## 🔄 Alternatives Considered
|
||||
|
||||
Have you considered any alternative solutions or workarounds?
|
||||
|
||||
## 📸 Mockups / Examples
|
||||
|
||||
If applicable, add mockups, screenshots, or examples from other applications.
|
||||
|
||||
## 📋 Additional Context
|
||||
|
||||
Add any other context or information about the feature request here.
|
||||
|
||||
## ✅ Checklist
|
||||
|
||||
- [ ] I have searched existing issues to make sure this isn't a duplicate
|
||||
- [ ] I have considered if this aligns with the project's goals (Windows 11-style clipboard manager)
|
||||
- [ ] I am willing to help implement this feature (optional)
|
||||
25
.github/ISSUE_TEMPLATE/question.md
vendored
Normal file
25
.github/ISSUE_TEMPLATE/question.md
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
---
|
||||
name: Question / Help
|
||||
about: Ask a question or get help with installation/usage
|
||||
title: '[QUESTION] '
|
||||
labels: ['question']
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
## ❓ Question
|
||||
|
||||
What would you like to know or get help with?
|
||||
|
||||
## 🖥️ Environment (if applicable)
|
||||
|
||||
- **OS**: [e.g., Ubuntu 22.04, Fedora 39, Arch Linux]
|
||||
- **Desktop Environment**: [e.g., GNOME 45, KDE Plasma 5.27]
|
||||
- **Display Server**: [X11 / Wayland]
|
||||
|
||||
## 📝 What I've Tried
|
||||
|
||||
Describe what you've already attempted or researched.
|
||||
|
||||
## 📄 Additional Context
|
||||
|
||||
Add any other context, screenshots, or information here.
|
||||
46
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
46
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
@@ -0,0 +1,46 @@
|
||||
## 📝 Description
|
||||
|
||||
<!-- Describe your changes in detail -->
|
||||
|
||||
## 🔗 Related Issue
|
||||
|
||||
<!-- Link to the issue this PR addresses (if any) -->
|
||||
Fixes #
|
||||
|
||||
## 🧪 Type of Change
|
||||
|
||||
<!-- Mark the appropriate option with an 'x' -->
|
||||
|
||||
- [ ] 🐛 Bug fix (non-breaking change that fixes an issue)
|
||||
- [ ] ✨ New feature (non-breaking change that adds functionality)
|
||||
- [ ] 💥 Breaking change (fix or feature that would cause existing functionality to change)
|
||||
- [ ] 📚 Documentation update
|
||||
- [ ] 🎨 Style/UI change
|
||||
- [ ] ♻️ Refactoring (no functional changes)
|
||||
- [ ] ⚡ Performance improvement
|
||||
- [ ] 🧹 Chore (build process, dependencies, etc.)
|
||||
|
||||
## 📸 Screenshots (if applicable)
|
||||
|
||||
<!-- Add screenshots to show UI changes -->
|
||||
|
||||
## ✅ Checklist
|
||||
|
||||
<!-- Mark completed items with an 'x' -->
|
||||
|
||||
- [ ] My code follows the project's code style
|
||||
- [ ] I have run `make lint` and `make format`
|
||||
- [ ] I have tested my changes locally
|
||||
- [ ] I have added/updated documentation as needed
|
||||
- [ ] My changes don't introduce new warnings
|
||||
- [ ] I have tested on both X11 and Wayland (if applicable)
|
||||
|
||||
## 🖥️ Testing Environment
|
||||
|
||||
- **OS**:
|
||||
- **Desktop Environment**:
|
||||
- **Display Server**: [X11 / Wayland]
|
||||
|
||||
## 📋 Additional Notes
|
||||
|
||||
<!-- Add any additional information for reviewers -->
|
||||
81
.github/SECURITY.md
vendored
Normal file
81
.github/SECURITY.md
vendored
Normal file
@@ -0,0 +1,81 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| 1.x.x | :white_check_mark: |
|
||||
| < 1.0 | :x: |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
We take security seriously. If you discover a security vulnerability, please follow these steps:
|
||||
|
||||
### 🔒 Private Disclosure
|
||||
|
||||
**Do NOT open a public issue for security vulnerabilities.**
|
||||
|
||||
Instead, please report security issues via one of these methods:
|
||||
|
||||
1. **GitHub Security Advisory**: Use [GitHub's private vulnerability reporting](https://github.com/gustavosett/Windows-11-Clipboard-History-For-Linux/security/advisories/new)
|
||||
|
||||
2. **Email**: gustaavoribeeiro@hotmail.com
|
||||
|
||||
### 📝 What to Include
|
||||
|
||||
When reporting a vulnerability, please include:
|
||||
|
||||
- **Description** of the vulnerability
|
||||
- **Steps to reproduce** the issue
|
||||
- **Potential impact** of the vulnerability
|
||||
- **Suggested fix** (if you have one)
|
||||
- **Your contact information** for follow-up questions
|
||||
|
||||
### ⏱️ Response Timeline
|
||||
|
||||
- **Initial Response**: Within 48 hours
|
||||
- **Status Update**: Within 1 week
|
||||
- **Fix Timeline**: Depends on severity
|
||||
- Critical: 24-72 hours
|
||||
- High: 1 week
|
||||
- Medium: 2 weeks
|
||||
- Low: Next release
|
||||
|
||||
### 🎁 Recognition
|
||||
|
||||
We appreciate security researchers who help keep our project safe. With your permission, we will:
|
||||
|
||||
- Acknowledge your contribution in release notes
|
||||
- Add you to our security hall of fame (if created in the future)
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
When using Win11 Clipboard History:
|
||||
|
||||
1. **Keep Updated**: Always use the latest version
|
||||
2. **Build from Source**: When possible, verify the source code
|
||||
3. **Check Signatures**: Verify release artifacts when available
|
||||
4. **Report Issues**: Help us by reporting any suspicious behavior
|
||||
|
||||
## Known Security Considerations
|
||||
|
||||
### Clipboard Data
|
||||
|
||||
- Clipboard history is stored **locally only**
|
||||
- No data is transmitted over the network
|
||||
- History is stored in memory (not persisted to disk by default)
|
||||
- Sensitive data copied to clipboard will be stored in history
|
||||
|
||||
### Permissions
|
||||
|
||||
- **Global hotkey capture**: Required for Super+V functionality
|
||||
- **System tray**: For background operation
|
||||
- **Clipboard access**: Core functionality
|
||||
|
||||
### Wayland Security
|
||||
|
||||
On Wayland, clipboard access follows the compositor's security model, which may restrict access to clipboard contents from background applications in some configurations.
|
||||
|
||||
---
|
||||
|
||||
Thank you for helping keep Win11 Clipboard History secure! 🔐
|
||||
58
.github/dependabot.yml
vendored
Normal file
58
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,58 @@
|
||||
version: 2
|
||||
updates:
|
||||
# Maintain npm dependencies
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
open-pull-requests-limit: 5
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "npm"
|
||||
commit-message:
|
||||
prefix: "chore(deps):"
|
||||
groups:
|
||||
dev-dependencies:
|
||||
dependency-type: "development"
|
||||
patterns:
|
||||
- "*"
|
||||
production-dependencies:
|
||||
dependency-type: "production"
|
||||
patterns:
|
||||
- "*"
|
||||
|
||||
# Maintain Cargo dependencies
|
||||
- package-ecosystem: "cargo"
|
||||
directory: "/src-tauri"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
open-pull-requests-limit: 5
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "rust"
|
||||
commit-message:
|
||||
prefix: "chore(deps):"
|
||||
groups:
|
||||
tauri:
|
||||
patterns:
|
||||
- "tauri*"
|
||||
all-cargo:
|
||||
patterns:
|
||||
- "*"
|
||||
exclude-patterns:
|
||||
- "tauri*"
|
||||
|
||||
# Maintain GitHub Actions
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
open-pull-requests-limit: 3
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "github-actions"
|
||||
commit-message:
|
||||
prefix: "chore(ci):"
|
||||
146
.github/workflows/ci.yml
vendored
Normal file
146
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,146 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master, main, develop]
|
||||
pull_request:
|
||||
branches: [master, main]
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
# Lint and format check
|
||||
lint:
|
||||
name: Lint & Format
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-action@stable
|
||||
with:
|
||||
components: rustfmt, clippy
|
||||
|
||||
- name: Cache Cargo
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/bin/
|
||||
~/.cargo/registry/index/
|
||||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
src-tauri/target/
|
||||
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential curl wget file \
|
||||
libssl-dev libayatana-appindicator3-dev librsvg2-dev libxdo-dev \
|
||||
libgtk-3-dev libglib2.0-dev
|
||||
|
||||
- name: Install npm dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run ESLint
|
||||
run: npm run lint
|
||||
|
||||
- name: Check TypeScript
|
||||
run: npm run build
|
||||
|
||||
- name: Check Rust formatting
|
||||
run: cd src-tauri && cargo fmt --all -- --check
|
||||
|
||||
- name: Run Clippy
|
||||
run: cd src-tauri && cargo clippy -- -D warnings
|
||||
|
||||
# Build for Linux
|
||||
build-linux:
|
||||
name: Build Linux
|
||||
runs-on: ubuntu-latest
|
||||
needs: lint
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-action@stable
|
||||
|
||||
- name: Cache Cargo
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/bin/
|
||||
~/.cargo/registry/index/
|
||||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
src-tauri/target/
|
||||
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential curl wget file \
|
||||
libssl-dev libayatana-appindicator3-dev librsvg2-dev libxdo-dev \
|
||||
libgtk-3-dev libglib2.0-dev
|
||||
|
||||
- name: Install npm dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build Tauri app
|
||||
run: npm run tauri:build
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: linux-artifacts
|
||||
path: |
|
||||
src-tauri/target/release/bundle/deb/*.deb
|
||||
src-tauri/target/release/bundle/appimage/*.AppImage
|
||||
src-tauri/target/release/bundle/rpm/*.rpm
|
||||
if-no-files-found: warn
|
||||
|
||||
# Security audit
|
||||
security:
|
||||
name: Security Audit
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-action@stable
|
||||
|
||||
- name: Install cargo-audit
|
||||
run: cargo install cargo-audit
|
||||
|
||||
- name: Run security audit
|
||||
run: cd src-tauri && cargo audit
|
||||
continue-on-error: true
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install npm dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run npm audit
|
||||
run: npm audit --audit-level=high
|
||||
continue-on-error: true
|
||||
128
.github/workflows/release.yml
vendored
Normal file
128
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,128 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
create-release:
|
||||
name: Create Release
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
release_id: ${{ steps.create-release.outputs.id }}
|
||||
upload_url: ${{ steps.create-release.outputs.upload_url }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Get version from tag
|
||||
id: get_version
|
||||
run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create Release
|
||||
id: create-release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
tag_name: ${{ github.ref_name }}
|
||||
name: Release ${{ github.ref_name }}
|
||||
body: |
|
||||
## Win11 Clipboard History ${{ github.ref_name }}
|
||||
|
||||
A Windows 11-style Clipboard History Manager for Linux.
|
||||
|
||||
### Installation
|
||||
|
||||
#### Ubuntu/Debian
|
||||
Download the `.deb` file and install:
|
||||
```bash
|
||||
sudo dpkg -i win11-clipboard-history_${{ steps.get_version.outputs.VERSION }}_amd64.deb
|
||||
```
|
||||
|
||||
#### Fedora/RHEL
|
||||
Download the `.rpm` file and install:
|
||||
```bash
|
||||
sudo rpm -i win11-clipboard-history-${{ steps.get_version.outputs.VERSION }}-1.x86_64.rpm
|
||||
```
|
||||
|
||||
#### Any Linux (AppImage)
|
||||
Download the `.AppImage` file, make it executable, and run:
|
||||
```bash
|
||||
chmod +x win11-clipboard-history_${{ steps.get_version.outputs.VERSION }}_amd64.AppImage
|
||||
./win11-clipboard-history_${{ steps.get_version.outputs.VERSION }}_amd64.AppImage
|
||||
```
|
||||
|
||||
### Changelog
|
||||
See the [commit history](https://github.com/gustavosett/Windows-11-Clipboard-History-For-Linux/commits/${{ github.ref_name }}) for changes.
|
||||
draft: false
|
||||
prerelease: ${{ contains(github.ref_name, 'alpha') || contains(github.ref_name, 'beta') || contains(github.ref_name, 'rc') }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
build-linux:
|
||||
name: Build Linux
|
||||
runs-on: ubuntu-latest
|
||||
needs: create-release
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-action@stable
|
||||
|
||||
- name: Cache Cargo
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/bin/
|
||||
~/.cargo/registry/index/
|
||||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
src-tauri/target/
|
||||
key: ${{ runner.os }}-cargo-release-${{ hashFiles('**/Cargo.lock') }}
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential curl wget file \
|
||||
libssl-dev libayatana-appindicator3-dev librsvg2-dev libxdo-dev \
|
||||
libgtk-3-dev libglib2.0-dev
|
||||
|
||||
- name: Install npm dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build Tauri app
|
||||
run: npm run tauri:build
|
||||
|
||||
- name: Upload DEB to Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
files: src-tauri/target/release/bundle/deb/*.deb
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Upload AppImage to Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
files: src-tauri/target/release/bundle/appimage/*.AppImage
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Upload RPM to Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
files: src-tauri/target/release/bundle/rpm/*.rpm
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
40
.github/workflows/stale.yml
vendored
Normal file
40
.github/workflows/stale.yml
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
name: Stale Issue Handler
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 0 * * *' # Run daily at midnight UTC
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@v9
|
||||
with:
|
||||
stale-issue-message: |
|
||||
This issue has been automatically marked as stale because it has not had recent activity.
|
||||
It will be closed in 14 days if no further activity occurs.
|
||||
|
||||
If this issue is still relevant, please comment or update it to keep it open.
|
||||
stale-pr-message: |
|
||||
This pull request has been automatically marked as stale because it has not had recent activity.
|
||||
It will be closed in 14 days if no further activity occurs.
|
||||
|
||||
Please rebase and update your PR if you'd like to continue with it.
|
||||
close-issue-message: |
|
||||
This issue has been closed due to inactivity.
|
||||
Feel free to reopen if you believe this issue is still relevant.
|
||||
close-pr-message: |
|
||||
This pull request has been closed due to inactivity.
|
||||
Feel free to reopen or create a new PR if you'd like to continue.
|
||||
days-before-stale: 60
|
||||
days-before-close: 14
|
||||
stale-issue-label: 'stale'
|
||||
stale-pr-label: 'stale'
|
||||
exempt-issue-labels: 'pinned,security,bug,help wanted'
|
||||
exempt-pr-labels: 'pinned,security'
|
||||
exempt-all-milestones: true
|
||||
36
.gitignore
vendored
Normal file
36
.gitignore
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
target/
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
src-tauri/target/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Test
|
||||
coverage/
|
||||
|
||||
# Tauri
|
||||
src-tauri/WixTools/
|
||||
src-tauri/Cargo.lock
|
||||
8
.prettierrc
Normal file
8
.prettierrc
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "es5",
|
||||
"printWidth": 100,
|
||||
"plugins": []
|
||||
}
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 Win11 Clipboard History Contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
269
Makefile
Normal file
269
Makefile
Normal file
@@ -0,0 +1,269 @@
|
||||
# Win11 Clipboard History - Makefile
|
||||
# Cross-distro build and install for Ubuntu, Debian, Fedora, and Arch Linux
|
||||
|
||||
SHELL := /bin/bash
|
||||
APP_NAME := win11-clipboard-history
|
||||
VERSION := 1.0.0
|
||||
PREFIX ?= /usr/local
|
||||
BINDIR := $(PREFIX)/bin
|
||||
DATADIR := $(PREFIX)/share
|
||||
DESTDIR ?=
|
||||
|
||||
# Detect distro
|
||||
DISTRO := $(shell \
|
||||
if [ -f /etc/os-release ]; then \
|
||||
. /etc/os-release && echo $$ID; \
|
||||
elif [ -f /etc/debian_version ]; then \
|
||||
echo "debian"; \
|
||||
elif [ -f /etc/fedora-release ]; then \
|
||||
echo "fedora"; \
|
||||
elif [ -f /etc/arch-release ]; then \
|
||||
echo "arch"; \
|
||||
else \
|
||||
echo "unknown"; \
|
||||
fi)
|
||||
|
||||
# Colors for pretty output
|
||||
CYAN := \033[0;36m
|
||||
GREEN := \033[0;32m
|
||||
YELLOW := \033[0;33m
|
||||
RED := \033[0;31m
|
||||
RESET := \033[0m
|
||||
|
||||
.PHONY: all help deps deps-ubuntu deps-debian deps-fedora deps-arch \
|
||||
rust node check-deps dev build install uninstall clean run \
|
||||
lint format test
|
||||
|
||||
all: build
|
||||
|
||||
help:
|
||||
@echo -e "$(CYAN)╔════════════════════════════════════════════════════════════════╗$(RESET)"
|
||||
@echo -e "$(CYAN)║ Win11 Clipboard History - Build Commands ║$(RESET)"
|
||||
@echo -e "$(CYAN)╚════════════════════════════════════════════════════════════════╝$(RESET)"
|
||||
@echo ""
|
||||
@echo -e "$(GREEN)Setup:$(RESET)"
|
||||
@echo " make deps - Install system dependencies (auto-detect distro)"
|
||||
@echo " make deps-ubuntu - Install dependencies for Ubuntu"
|
||||
@echo " make deps-debian - Install dependencies for Debian"
|
||||
@echo " make deps-fedora - Install dependencies for Fedora"
|
||||
@echo " make deps-arch - Install dependencies for Arch Linux"
|
||||
@echo " make rust - Install Rust via rustup"
|
||||
@echo " make node - Install Node.js via nvm"
|
||||
@echo ""
|
||||
@echo -e "$(GREEN)Development:$(RESET)"
|
||||
@echo " make dev - Run in development mode (hot reload)"
|
||||
@echo " make run - Run the development version (clean env)"
|
||||
@echo " make build - Build production release"
|
||||
@echo " make lint - Run linters"
|
||||
@echo " make format - Format code"
|
||||
@echo ""
|
||||
@echo -e "$(GREEN)Installation:$(RESET)"
|
||||
@echo " make install - Install to system (requires sudo)"
|
||||
@echo " make uninstall - Remove from system (requires sudo)"
|
||||
@echo ""
|
||||
@echo -e "$(GREEN)Maintenance:$(RESET)"
|
||||
@echo " make clean - Remove build artifacts"
|
||||
@echo " make check-deps - Verify all dependencies are installed"
|
||||
@echo ""
|
||||
@echo -e "$(YELLOW)Detected distro: $(DISTRO)$(RESET)"
|
||||
|
||||
# ============================================================================
|
||||
# Dependencies
|
||||
# ============================================================================
|
||||
|
||||
deps:
|
||||
@echo -e "$(CYAN)Detected distribution: $(DISTRO)$(RESET)"
|
||||
ifeq ($(DISTRO),ubuntu)
|
||||
@$(MAKE) deps-ubuntu
|
||||
else ifeq ($(DISTRO),debian)
|
||||
@$(MAKE) deps-debian
|
||||
else ifeq ($(DISTRO),fedora)
|
||||
@$(MAKE) deps-fedora
|
||||
else ifeq ($(DISTRO),arch)
|
||||
@$(MAKE) deps-arch
|
||||
else ifeq ($(DISTRO),manjaro)
|
||||
@$(MAKE) deps-arch
|
||||
else ifeq ($(DISTRO),endeavouros)
|
||||
@$(MAKE) deps-arch
|
||||
else ifeq ($(DISTRO),linuxmint)
|
||||
@$(MAKE) deps-ubuntu
|
||||
else ifeq ($(DISTRO),pop)
|
||||
@$(MAKE) deps-ubuntu
|
||||
else
|
||||
@echo -e "$(RED)Unknown distribution: $(DISTRO)$(RESET)"
|
||||
@echo "Please install dependencies manually. See README.md"
|
||||
@exit 1
|
||||
endif
|
||||
|
||||
deps-ubuntu deps-debian:
|
||||
@echo -e "$(CYAN)Installing dependencies for Ubuntu/Debian...$(RESET)"
|
||||
sudo apt update
|
||||
sudo apt install -y \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
build-essential \
|
||||
curl \
|
||||
wget \
|
||||
file \
|
||||
libssl-dev \
|
||||
libayatana-appindicator3-dev \
|
||||
librsvg2-dev \
|
||||
libxdo-dev \
|
||||
libgtk-3-dev \
|
||||
libglib2.0-dev \
|
||||
pkg-config
|
||||
@echo -e "$(GREEN)✓ Dependencies installed successfully$(RESET)"
|
||||
|
||||
deps-fedora:
|
||||
@echo -e "$(CYAN)Installing dependencies for Fedora...$(RESET)"
|
||||
sudo dnf install -y \
|
||||
webkit2gtk4.1-devel \
|
||||
openssl-devel \
|
||||
curl \
|
||||
wget \
|
||||
file \
|
||||
libappindicator-gtk3-devel \
|
||||
librsvg2-devel \
|
||||
libxdo-devel \
|
||||
gtk3-devel \
|
||||
glib2-devel \
|
||||
pkg-config \
|
||||
@development-tools
|
||||
@echo -e "$(GREEN)✓ Dependencies installed successfully$(RESET)"
|
||||
|
||||
deps-arch:
|
||||
@echo -e "$(CYAN)Installing dependencies for Arch Linux...$(RESET)"
|
||||
sudo pacman -Syu --needed --noconfirm \
|
||||
webkit2gtk-4.1 \
|
||||
base-devel \
|
||||
curl \
|
||||
wget \
|
||||
file \
|
||||
openssl \
|
||||
libappindicator-gtk3 \
|
||||
librsvg \
|
||||
xdotool \
|
||||
gtk3 \
|
||||
glib2 \
|
||||
pkgconf
|
||||
@echo -e "$(GREEN)✓ Dependencies installed successfully$(RESET)"
|
||||
|
||||
rust:
|
||||
@echo -e "$(CYAN)Installing Rust via rustup...$(RESET)"
|
||||
@if command -v rustc &> /dev/null; then \
|
||||
echo -e "$(YELLOW)Rust is already installed: $$(rustc --version)$(RESET)"; \
|
||||
else \
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y; \
|
||||
echo -e "$(GREEN)✓ Rust installed. Run 'source ~/.cargo/env' to update PATH$(RESET)"; \
|
||||
fi
|
||||
|
||||
node:
|
||||
@echo -e "$(CYAN)Installing Node.js via nvm...$(RESET)"
|
||||
@if command -v node &> /dev/null; then \
|
||||
echo -e "$(YELLOW)Node.js is already installed: $$(node --version)$(RESET)"; \
|
||||
else \
|
||||
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash; \
|
||||
export NVM_DIR="$$HOME/.nvm"; \
|
||||
[ -s "$$NVM_DIR/nvm.sh" ] && \. "$$NVM_DIR/nvm.sh"; \
|
||||
nvm install 20; \
|
||||
echo -e "$(GREEN)✓ Node.js installed$(RESET)"; \
|
||||
fi
|
||||
|
||||
check-deps:
|
||||
@echo -e "$(CYAN)Checking dependencies...$(RESET)"
|
||||
@echo ""
|
||||
@echo "System tools:"
|
||||
@command -v rustc &> /dev/null && echo -e " $(GREEN)✓$(RESET) Rust: $$(rustc --version)" || echo -e " $(RED)✗$(RESET) Rust: not found"
|
||||
@command -v cargo &> /dev/null && echo -e " $(GREEN)✓$(RESET) Cargo: $$(cargo --version)" || echo -e " $(RED)✗$(RESET) Cargo: not found"
|
||||
@command -v node &> /dev/null && echo -e " $(GREEN)✓$(RESET) Node.js: $$(node --version)" || echo -e " $(RED)✗$(RESET) Node.js: not found"
|
||||
@command -v npm &> /dev/null && echo -e " $(GREEN)✓$(RESET) npm: $$(npm --version)" || echo -e " $(RED)✗$(RESET) npm: not found"
|
||||
@echo ""
|
||||
@echo "Libraries:"
|
||||
@pkg-config --exists webkit2gtk-4.1 2>/dev/null && echo -e " $(GREEN)✓$(RESET) webkit2gtk-4.1" || echo -e " $(RED)✗$(RESET) webkit2gtk-4.1"
|
||||
@pkg-config --exists gtk+-3.0 2>/dev/null && echo -e " $(GREEN)✓$(RESET) gtk+-3.0" || echo -e " $(RED)✗$(RESET) gtk+-3.0"
|
||||
@pkg-config --exists glib-2.0 2>/dev/null && echo -e " $(GREEN)✓$(RESET) glib-2.0" || echo -e " $(RED)✗$(RESET) glib-2.0"
|
||||
@pkg-config --exists openssl 2>/dev/null && echo -e " $(GREEN)✓$(RESET) openssl" || echo -e " $(RED)✗$(RESET) openssl"
|
||||
@echo ""
|
||||
|
||||
# ============================================================================
|
||||
# Development
|
||||
# ============================================================================
|
||||
|
||||
dev: node_modules
|
||||
@echo -e "$(CYAN)Starting development server...$(RESET)"
|
||||
@./scripts/run-dev.sh
|
||||
|
||||
run: node_modules
|
||||
@echo -e "$(CYAN)Running with clean environment...$(RESET)"
|
||||
@./scripts/run-dev.sh
|
||||
|
||||
node_modules: package.json
|
||||
@echo -e "$(CYAN)Installing npm dependencies...$(RESET)"
|
||||
npm install
|
||||
@touch node_modules
|
||||
|
||||
# ============================================================================
|
||||
# Build
|
||||
# ============================================================================
|
||||
|
||||
build: node_modules
|
||||
@echo -e "$(CYAN)Building production release...$(RESET)"
|
||||
npm run tauri:build
|
||||
@echo -e "$(GREEN)✓ Build complete!$(RESET)"
|
||||
@echo -e "$(YELLOW)Packages available in: src-tauri/target/release/bundle/$(RESET)"
|
||||
|
||||
# ============================================================================
|
||||
# Install / Uninstall
|
||||
# ============================================================================
|
||||
|
||||
install: build
|
||||
@echo -e "$(CYAN)Installing $(APP_NAME)...$(RESET)"
|
||||
install -Dm755 src-tauri/target/release/$(APP_NAME) $(DESTDIR)$(BINDIR)/$(APP_NAME)
|
||||
install -Dm644 src-tauri/icons/128x128.png $(DESTDIR)$(DATADIR)/icons/hicolor/128x128/apps/$(APP_NAME).png
|
||||
install -Dm644 src-tauri/icons/icon.png $(DESTDIR)$(DATADIR)/icons/hicolor/256x256/apps/$(APP_NAME).png
|
||||
@# Create desktop entry
|
||||
@mkdir -p $(DESTDIR)$(DATADIR)/applications
|
||||
@echo "[Desktop Entry]" > $(DESTDIR)$(DATADIR)/applications/$(APP_NAME).desktop
|
||||
@echo "Name=Clipboard History" >> $(DESTDIR)$(DATADIR)/applications/$(APP_NAME).desktop
|
||||
@echo "Comment=Windows 11-style Clipboard History Manager" >> $(DESTDIR)$(DATADIR)/applications/$(APP_NAME).desktop
|
||||
@echo "Exec=$(BINDIR)/$(APP_NAME)" >> $(DESTDIR)$(DATADIR)/applications/$(APP_NAME).desktop
|
||||
@echo "Icon=$(APP_NAME)" >> $(DESTDIR)$(DATADIR)/applications/$(APP_NAME).desktop
|
||||
@echo "Terminal=false" >> $(DESTDIR)$(DATADIR)/applications/$(APP_NAME).desktop
|
||||
@echo "Type=Application" >> $(DESTDIR)$(DATADIR)/applications/$(APP_NAME).desktop
|
||||
@echo "Categories=Utility;" >> $(DESTDIR)$(DATADIR)/applications/$(APP_NAME).desktop
|
||||
@echo "Keywords=clipboard;history;paste;copy;" >> $(DESTDIR)$(DATADIR)/applications/$(APP_NAME).desktop
|
||||
@echo "StartupWMClass=$(APP_NAME)" >> $(DESTDIR)$(DATADIR)/applications/$(APP_NAME).desktop
|
||||
@echo -e "$(GREEN)✓ Installed successfully$(RESET)"
|
||||
@echo -e "$(YELLOW)You may need to log out and back in for the desktop entry to appear$(RESET)"
|
||||
|
||||
uninstall:
|
||||
@echo -e "$(CYAN)Uninstalling $(APP_NAME)...$(RESET)"
|
||||
rm -f $(DESTDIR)$(BINDIR)/$(APP_NAME)
|
||||
rm -f $(DESTDIR)$(DATADIR)/icons/hicolor/128x128/apps/$(APP_NAME).png
|
||||
rm -f $(DESTDIR)$(DATADIR)/icons/hicolor/256x256/apps/$(APP_NAME).png
|
||||
rm -f $(DESTDIR)$(DATADIR)/applications/$(APP_NAME).desktop
|
||||
@echo -e "$(GREEN)✓ Uninstalled successfully$(RESET)"
|
||||
|
||||
# ============================================================================
|
||||
# Code Quality
|
||||
# ============================================================================
|
||||
|
||||
lint:
|
||||
@echo -e "$(CYAN)Running linters...$(RESET)"
|
||||
npm run lint
|
||||
cd src-tauri && cargo clippy -- -D warnings
|
||||
|
||||
format:
|
||||
@echo -e "$(CYAN)Formatting code...$(RESET)"
|
||||
npm run format
|
||||
cd src-tauri && cargo fmt
|
||||
|
||||
# ============================================================================
|
||||
# Clean
|
||||
# ============================================================================
|
||||
|
||||
clean:
|
||||
@echo -e "$(CYAN)Cleaning build artifacts...$(RESET)"
|
||||
rm -rf node_modules
|
||||
rm -rf dist
|
||||
rm -rf src-tauri/target
|
||||
@echo -e "$(GREEN)✓ Cleaned$(RESET)"
|
||||
360
README.md
Normal file
360
README.md
Normal file
@@ -0,0 +1,360 @@
|
||||
# 📋 Win11 Clipboard History
|
||||
|
||||
<div align="center">
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
**A beautiful, Windows 11-style Clipboard History Manager for Linux**
|
||||
|
||||
Built with 🦀 Rust + ⚡ Tauri v2 + ⚛️ React + 🎨 Tailwind CSS
|
||||
|
||||
[Features](#-features) • [Installation](#-installation) • [Development](#-development) • [Contributing](#-contributing)
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- 🎨 **Windows 11 Design** - Pixel-perfect recreation of the Win+V clipboard UI with Acrylic/Mica glassmorphism effects
|
||||
- 🌙 **Dark/Light Mode** - Automatically detects system theme preference
|
||||
- ⌨️ **Global Hotkey** - Press `Super+V` or `Ctrl+Alt+V` to open from anywhere
|
||||
- 📌 **Pin Items** - Keep important clipboard entries at the top
|
||||
- 🖼️ **Image Support** - Copy and paste images with preview thumbnails
|
||||
- 🚀 **Blazing Fast** - Written in Rust for maximum performance
|
||||
- 🔒 **Privacy First** - All data stays local on your machine
|
||||
- 🖱️ **Smart Positioning** - Window appears at your cursor position
|
||||
- 💨 **System Tray** - Runs silently in the background
|
||||
- 🐧 **Wayland & X11** - Works on both display servers
|
||||
|
||||
## 📦 Installation
|
||||
|
||||
### Quick Start (Recommended)
|
||||
|
||||
The easiest way to get started:
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/gustavosett/Windows-11-Clipboard-History-For-Linux.git
|
||||
cd win11-clipboard-history
|
||||
|
||||
# Install system dependencies (auto-detects your distro)
|
||||
make deps
|
||||
|
||||
# Install Rust and Node.js if needed
|
||||
make rust
|
||||
make node
|
||||
source ~/.cargo/env # Reload shell environment
|
||||
|
||||
# Build and install
|
||||
make build
|
||||
sudo make install
|
||||
```
|
||||
|
||||
### Distribution-Specific Dependencies
|
||||
|
||||
<details>
|
||||
<summary><b>🟠 Ubuntu / Debian / Linux Mint / Pop!_OS</b></summary>
|
||||
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install -y \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
build-essential \
|
||||
curl \
|
||||
wget \
|
||||
file \
|
||||
libssl-dev \
|
||||
libayatana-appindicator3-dev \
|
||||
librsvg2-dev \
|
||||
libxdo-dev \
|
||||
libgtk-3-dev \
|
||||
libglib2.0-dev \
|
||||
pkg-config
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>🔵 Fedora</b></summary>
|
||||
|
||||
```bash
|
||||
sudo dnf install -y \
|
||||
webkit2gtk4.1-devel \
|
||||
openssl-devel \
|
||||
curl \
|
||||
wget \
|
||||
file \
|
||||
libappindicator-gtk3-devel \
|
||||
librsvg2-devel \
|
||||
libxdo-devel \
|
||||
gtk3-devel \
|
||||
glib2-devel \
|
||||
pkg-config \
|
||||
@development-tools
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>🟣 Arch Linux / Manjaro / EndeavourOS</b></summary>
|
||||
|
||||
```bash
|
||||
sudo pacman -Syu --needed \
|
||||
webkit2gtk-4.1 \
|
||||
base-devel \
|
||||
curl \
|
||||
wget \
|
||||
file \
|
||||
openssl \
|
||||
libappindicator-gtk3 \
|
||||
librsvg \
|
||||
xdotool \
|
||||
gtk3 \
|
||||
glib2 \
|
||||
pkgconf
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### Install Rust
|
||||
|
||||
```bash
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||
source ~/.cargo/env
|
||||
```
|
||||
|
||||
### Install Node.js (v18+)
|
||||
|
||||
```bash
|
||||
# Using nvm (recommended)
|
||||
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash
|
||||
source ~/.nvm/nvm.sh
|
||||
nvm install 20
|
||||
nvm use 20
|
||||
```
|
||||
|
||||
### Build from Source
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/gustavosett/Windows-11-Clipboard-History-For-Linux.git
|
||||
cd win11-clipboard-history
|
||||
|
||||
# Install npm dependencies
|
||||
npm install
|
||||
|
||||
# Build the application
|
||||
npm run tauri:build
|
||||
|
||||
# The built packages will be in:
|
||||
# - Binary: src-tauri/target/release/win11-clipboard-history
|
||||
# - DEB package: src-tauri/target/release/bundle/deb/
|
||||
# - RPM package: src-tauri/target/release/bundle/rpm/
|
||||
# - AppImage: src-tauri/target/release/bundle/appimage/
|
||||
```
|
||||
|
||||
## 🛠️ Development
|
||||
|
||||
### Quick Start
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Run in development mode (hot reload enabled)
|
||||
make dev
|
||||
# OR
|
||||
./scripts/run-dev.sh
|
||||
```
|
||||
|
||||
> **Note for VS Code Snap users**: If you're using VS Code installed via Snap, use `make dev` or `./scripts/run-dev.sh` instead of `npm run tauri:dev` directly. This script cleans the environment to avoid library conflicts.
|
||||
|
||||
### Makefile Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `make help` | Show all available commands |
|
||||
| `make deps` | Install system dependencies (auto-detect distro) |
|
||||
| `make deps-ubuntu` | Install dependencies for Ubuntu/Debian |
|
||||
| `make deps-fedora` | Install dependencies for Fedora |
|
||||
| `make deps-arch` | Install dependencies for Arch Linux |
|
||||
| `make rust` | Install Rust via rustup |
|
||||
| `make node` | Install Node.js via nvm |
|
||||
| `make check-deps` | Verify all dependencies are installed |
|
||||
| `make dev` | Run in development mode |
|
||||
| `make build` | Build production release |
|
||||
| `make install` | Install to system (requires sudo) |
|
||||
| `make uninstall` | Remove from system (requires sudo) |
|
||||
| `make clean` | Remove build artifacts |
|
||||
| `make lint` | Run linters |
|
||||
| `make format` | Format code |
|
||||
|
||||
### npm Scripts
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `npm run dev` | Start Vite dev server (frontend only) |
|
||||
| `npm run tauri:dev` | Start full Tauri development mode |
|
||||
| `npm run tauri:build` | Build production release |
|
||||
| `npm run build` | Build frontend only |
|
||||
| `npm run lint` | Run ESLint |
|
||||
| `npm run format` | Format code with Prettier |
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
win11-clipboard-history/
|
||||
├── src/ # React frontend
|
||||
│ ├── components/ # UI components
|
||||
│ │ ├── EmptyState.tsx # Empty history state
|
||||
│ │ ├── Header.tsx # App header with actions
|
||||
│ │ ├── HistoryItem.tsx # Clipboard item card
|
||||
│ │ └── TabBar.tsx # Tab navigation
|
||||
│ ├── hooks/ # React hooks
|
||||
│ │ ├── useClipboardHistory.ts
|
||||
│ │ └── useDarkMode.ts
|
||||
│ ├── types/ # TypeScript types
|
||||
│ │ └── clipboard.ts
|
||||
│ ├── App.tsx # Main app component
|
||||
│ ├── index.css # Global styles + Tailwind
|
||||
│ └── main.tsx # Entry point
|
||||
├── src-tauri/ # Rust backend
|
||||
│ ├── src/
|
||||
│ │ ├── main.rs # App setup, tray, commands
|
||||
│ │ ├── lib.rs # Library exports
|
||||
│ │ ├── clipboard_manager.rs # Clipboard operations
|
||||
│ │ └── hotkey_manager.rs # Global shortcuts
|
||||
│ ├── capabilities/ # Tauri permissions
|
||||
│ ├── icons/ # App icons
|
||||
│ ├── Cargo.toml # Rust dependencies
|
||||
│ └── tauri.conf.json # Tauri configuration
|
||||
├── scripts/
|
||||
│ └── run-dev.sh # Clean environment dev script
|
||||
├── Makefile # Build automation
|
||||
├── tailwind.config.js # Win11 theme config
|
||||
├── vite.config.ts # Vite configuration
|
||||
└── package.json # Node dependencies
|
||||
```
|
||||
|
||||
### Global Hotkey Permissions
|
||||
|
||||
On Linux with X11, global keyboard capture may require the user to be in the `input` group:
|
||||
|
||||
```bash
|
||||
sudo usermod -aG input $USER
|
||||
# Log out and back in for changes to take effect
|
||||
```
|
||||
|
||||
On Wayland, permissions are typically handled automatically by the compositor.
|
||||
|
||||
## 🐧 Platform Support
|
||||
|
||||
### Display Servers
|
||||
|
||||
| Display Server | Status | Notes |
|
||||
|----------------|--------|-------|
|
||||
| X11 | ✅ Full support | Global hotkeys work via rdev |
|
||||
| Wayland | ✅ Full support | Uses wl-clipboard for clipboard access |
|
||||
|
||||
### Tested Distributions
|
||||
|
||||
| Distribution | Version | Status |
|
||||
|--------------|---------|--------|
|
||||
| Ubuntu | 22.04+ | ✅ Tested |
|
||||
| Debian | 12+ | ✅ Tested |
|
||||
| Fedora | 38+ | ✅ Tested |
|
||||
| Arch Linux | Rolling | ✅ Tested |
|
||||
| Manjaro | Latest | ✅ Tested |
|
||||
| Linux Mint | 21+ | ✅ Tested |
|
||||
| Pop!_OS | 22.04+ | ✅ Tested |
|
||||
|
||||
## 🎨 Customization
|
||||
|
||||
### Changing the Hotkey
|
||||
|
||||
Edit `src-tauri/src/hotkey_manager.rs` to modify the global shortcut:
|
||||
|
||||
```rust
|
||||
// Current: Super+V or Ctrl+Alt+V
|
||||
Key::KeyV => {
|
||||
if super_pressed || (ctrl_pressed && alt_pressed) {
|
||||
callback();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Theme Colors
|
||||
|
||||
The Windows 11 color palette is defined in `tailwind.config.js`:
|
||||
|
||||
```js
|
||||
colors: {
|
||||
win11: {
|
||||
'bg-primary': '#202020',
|
||||
'bg-accent': '#0078d4',
|
||||
// ... customize as needed
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 🔧 Troubleshooting
|
||||
|
||||
### Application won't start
|
||||
|
||||
1. **Check dependencies**: Run `make check-deps` to verify all dependencies are installed
|
||||
2. **Wayland clipboard issues**: Ensure `wl-clipboard` is installed for Wayland support
|
||||
3. **VS Code Snap conflict**: Use `make dev` or `./scripts/run-dev.sh` instead of `npm run tauri:dev`
|
||||
|
||||
### Global hotkey not working
|
||||
|
||||
1. **X11**: Add user to input group: `sudo usermod -aG input $USER`
|
||||
2. **Wayland**: Some compositors may require additional permissions
|
||||
3. Try alternative hotkey `Ctrl+Alt+V` instead of `Super+V`
|
||||
|
||||
### Window not showing at cursor position
|
||||
|
||||
This may occur on some Wayland compositors. The window will fallback to a default position.
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
Contributions are welcome! Here's how you can help:
|
||||
|
||||
1. **Fork** the repository
|
||||
2. **Create** a feature branch (`git checkout -b feature/amazing-feature`)
|
||||
3. **Commit** your changes (`git commit -m 'Add amazing feature'`)
|
||||
4. **Push** to the branch (`git push origin feature/amazing-feature`)
|
||||
5. **Open** a Pull Request
|
||||
|
||||
### Development Guidelines
|
||||
|
||||
- Follow the existing code style
|
||||
- Run `make lint` and `make format` before committing
|
||||
- Write meaningful commit messages
|
||||
- Add tests for new features
|
||||
- Update documentation as needed
|
||||
|
||||
## 📄 License
|
||||
|
||||
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
||||
|
||||
## 🙏 Acknowledgments
|
||||
|
||||
- [Tauri](https://tauri.app/) - For the amazing Rust-based framework
|
||||
- [Windows 11](https://www.microsoft.com/windows/windows-11) - For the beautiful design inspiration
|
||||
- [rdev](https://github.com/Narsil/rdev) - For global keyboard capture
|
||||
- [arboard](https://github.com/1Password/arboard) - For cross-platform clipboard access
|
||||
- [wl-clipboard-rs](https://github.com/YaLTeR/wl-clipboard-rs) - For Wayland clipboard support
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
**If you find this project useful, please consider giving it a ⭐!**
|
||||
|
||||
Made with ❤️ for the Linux community
|
||||
|
||||
</div>
|
||||
29
eslint.config.js
Normal file
29
eslint.config.js
Normal file
@@ -0,0 +1,29 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist', 'src-tauri'] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
||||
},
|
||||
}
|
||||
)
|
||||
24
index.html
Normal file
24
index.html
Normal file
@@ -0,0 +1,24 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Clipboard History</title>
|
||||
<style>
|
||||
/* Prevent flash of white during load */
|
||||
html {
|
||||
background: transparent;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
4326
package-lock.json
generated
Normal file
4326
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
57
package.json
Normal file
57
package.json
Normal file
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"name": "win11-clipboard-history",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"description": "A Windows 11-style Clipboard History Manager for Linux built with Tauri v2",
|
||||
"author": "Your Name",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/gustavosett/Windows-11-Clipboard-History-For-Linux"
|
||||
},
|
||||
"keywords": [
|
||||
"clipboard",
|
||||
"linux",
|
||||
"tauri",
|
||||
"windows-11",
|
||||
"clipboard-manager",
|
||||
"rust"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"tauri:dev": "tauri dev",
|
||||
"tauri:build": "tauri build",
|
||||
"lint": "eslint . --report-unused-disable-directives --max-warnings 0",
|
||||
"format": "prettier --write \"src/**/*.{ts,tsx,css}\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
"@tauri-apps/plugin-shell": "^2.0.0",
|
||||
"clsx": "^2.1.0",
|
||||
"lucide-react": "^0.460.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.0.0",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.13.0",
|
||||
"@typescript-eslint/parser": "^8.49.0",
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9.14.0",
|
||||
"eslint-plugin-react-hooks": "^5.0.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.14",
|
||||
"postcss": "^8.4.49",
|
||||
"prettier": "^3.3.3",
|
||||
"tailwindcss": "^3.4.15",
|
||||
"typescript": "^5.6.3",
|
||||
"typescript-eslint": "^8.49.0",
|
||||
"vite": "^5.4.11"
|
||||
}
|
||||
}
|
||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
14
public/vite.svg
Normal file
14
public/vite.svg
Normal file
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#0078d4"/>
|
||||
<stop offset="100%" style="stop-color:#005a9e"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="32" height="32" rx="6" fill="url(#bg)"/>
|
||||
<rect x="8" y="6" width="16" height="20" rx="2" fill="white" opacity="0.95"/>
|
||||
<rect x="11" y="4" width="10" height="4" rx="1" fill="white"/>
|
||||
<rect x="10" y="12" width="12" height="2" rx="1" fill="#0078d4" opacity="0.5"/>
|
||||
<rect x="10" y="16" width="10" height="2" rx="1" fill="#0078d4" opacity="0.4"/>
|
||||
<rect x="10" y="20" width="8" height="2" rx="1" fill="#0078d4" opacity="0.3"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 751 B |
19
scripts/run-dev.sh
Executable file
19
scripts/run-dev.sh
Executable file
@@ -0,0 +1,19 @@
|
||||
#!/bin/bash
|
||||
# Clean environment script to avoid snap library conflicts
|
||||
|
||||
# Reset problematic environment variables
|
||||
unset GIO_MODULE_DIR
|
||||
unset GTK_PATH
|
||||
unset GTK_IM_MODULE_FILE
|
||||
unset GTK_EXE_PREFIX
|
||||
unset LOCPATH
|
||||
unset GSETTINGS_SCHEMA_DIR
|
||||
|
||||
# Use system XDG_DATA_DIRS
|
||||
export XDG_DATA_DIRS="/usr/local/share:/usr/share:/var/lib/snapd/desktop"
|
||||
|
||||
# Change to project directory
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
# Run tauri dev
|
||||
npm run tauri:dev
|
||||
63
src-tauri/Cargo.toml
Normal file
63
src-tauri/Cargo.toml
Normal file
@@ -0,0 +1,63 @@
|
||||
[package]
|
||||
name = "win11-clipboard-history"
|
||||
version = "1.0.0"
|
||||
description = "A Windows 11-style Clipboard History Manager for Linux"
|
||||
authors = ["Your Name"]
|
||||
license = "MIT"
|
||||
repository = "https://github.com/gustavosett/Windows-11-Clipboard-History-For-Linux"
|
||||
edition = "2021"
|
||||
rust-version = "1.70"
|
||||
|
||||
[lib]
|
||||
name = "win11_clipboard_history_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[[bin]]
|
||||
name = "win11-clipboard-history"
|
||||
path = "src/main.rs"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
# Tauri Core
|
||||
tauri = { version = "2", features = ["tray-icon", "image-png"] }
|
||||
tauri-plugin-shell = "2"
|
||||
|
||||
# Clipboard Management - wayland-data-control for native Wayland support
|
||||
arboard = { version = "3.4", features = ["image-data", "wayland-data-control"] }
|
||||
|
||||
# Global Hotkeys
|
||||
rdev = { version = "0.5", features = ["unstable_grab"] }
|
||||
|
||||
# Async Runtime
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
|
||||
# Serialization
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
# Image Handling
|
||||
image = "0.25"
|
||||
base64 = "0.22"
|
||||
|
||||
# Utilities
|
||||
once_cell = "1.19"
|
||||
parking_lot = "0.12"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
uuid = { version = "1.10", features = ["v4", "serde"] }
|
||||
|
||||
# X11 Simulation for paste injection (Linux)
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
enigo = "0.2"
|
||||
|
||||
[features]
|
||||
default = ["custom-protocol"]
|
||||
custom-protocol = ["tauri/custom-protocol"]
|
||||
|
||||
[profile.release]
|
||||
panic = "abort"
|
||||
codegen-units = 1
|
||||
lto = true
|
||||
opt-level = "z"
|
||||
strip = true
|
||||
3
src-tauri/build.rs
Normal file
3
src-tauri/build.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
29
src-tauri/capabilities/default.json
Normal file
29
src-tauri/capabilities/default.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:default",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-hide",
|
||||
"core:window:allow-show",
|
||||
"core:window:allow-set-focus",
|
||||
"core:window:allow-set-position",
|
||||
"core:window:allow-set-size",
|
||||
"core:window:allow-center",
|
||||
"core:window:allow-is-visible",
|
||||
"core:event:default",
|
||||
"core:event:allow-emit",
|
||||
"core:event:allow-listen",
|
||||
"core:app:default",
|
||||
"core:tray:default",
|
||||
"core:tray:allow-set-icon",
|
||||
"core:tray:allow-set-tooltip",
|
||||
"core:resources:default",
|
||||
"shell:default",
|
||||
"shell:allow-open"
|
||||
],
|
||||
"platforms": ["linux", "windows", "macOS"]
|
||||
}
|
||||
1
src-tauri/gen/schemas/acl-manifests.json
Normal file
1
src-tauri/gen/schemas/acl-manifests.json
Normal file
File diff suppressed because one or more lines are too long
1
src-tauri/gen/schemas/capabilities.json
Normal file
1
src-tauri/gen/schemas/capabilities.json
Normal file
@@ -0,0 +1 @@
|
||||
{"default":{"identifier":"default","description":"Capability for the main window","local":true,"windows":["main"],"permissions":["core:default","core:window:default","core:window:allow-close","core:window:allow-hide","core:window:allow-show","core:window:allow-set-focus","core:window:allow-set-position","core:window:allow-set-size","core:window:allow-center","core:window:allow-is-visible","core:event:default","core:event:allow-emit","core:event:allow-listen","core:app:default","core:tray:default","core:tray:allow-set-icon","core:tray:allow-set-tooltip","core:resources:default","shell:default","shell:allow-open"],"platforms":["linux","windows","macOS"]}}
|
||||
2564
src-tauri/gen/schemas/desktop-schema.json
Normal file
2564
src-tauri/gen/schemas/desktop-schema.json
Normal file
File diff suppressed because it is too large
Load Diff
2564
src-tauri/gen/schemas/linux-schema.json
Normal file
2564
src-tauri/gen/schemas/linux-schema.json
Normal file
File diff suppressed because it is too large
Load Diff
BIN
src-tauri/icons/128x128.png
Normal file
BIN
src-tauri/icons/128x128.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 435 B |
BIN
src-tauri/icons/128x128@2x.png
Normal file
BIN
src-tauri/icons/128x128@2x.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1022 B |
BIN
src-tauri/icons/32x32.png
Normal file
BIN
src-tauri/icons/32x32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 158 B |
28
src-tauri/icons/README.md
Normal file
28
src-tauri/icons/README.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# Icon Generation
|
||||
|
||||
To generate the required icon files, you can use ImageMagick:
|
||||
|
||||
```bash
|
||||
# Install ImageMagick if not already installed
|
||||
# Ubuntu/Debian: sudo apt install imagemagick
|
||||
# Fedora: sudo dnf install ImageMagick
|
||||
# Arch: sudo pacman -S imagemagick
|
||||
|
||||
# Generate PNG icons from SVG
|
||||
convert -background transparent icon.svg -resize 32x32 32x32.png
|
||||
convert -background transparent icon.svg -resize 128x128 128x128.png
|
||||
convert -background transparent icon.svg -resize 256x256 128x128@2x.png
|
||||
convert -background transparent icon.svg -resize 128x128 icon.png
|
||||
|
||||
# Generate ICO for Windows (if needed)
|
||||
convert icon.svg -resize 256x256 -define icon:auto-resize=256,128,64,48,32,16 icon.ico
|
||||
|
||||
# Generate ICNS for macOS (if needed)
|
||||
# You'll need to create an iconset folder and use iconutil on macOS
|
||||
```
|
||||
|
||||
Alternatively, use an online tool like:
|
||||
- https://realfavicongenerator.net/
|
||||
- https://cloudconvert.com/svg-to-png
|
||||
|
||||
For now, placeholder PNG files are included. Replace them with properly generated icons.
|
||||
BIN
src-tauri/icons/icon.png
Normal file
BIN
src-tauri/icons/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 435 B |
20
src-tauri/icons/icon.svg
Normal file
20
src-tauri/icons/icon.svg
Normal file
@@ -0,0 +1,20 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#0078d4"/>
|
||||
<stop offset="100%" style="stop-color:#005a9e"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<!-- Background -->
|
||||
<rect width="128" height="128" rx="24" fill="url(#bg)"/>
|
||||
<!-- Clipboard body -->
|
||||
<rect x="32" y="24" width="64" height="80" rx="6" fill="white" opacity="0.95"/>
|
||||
<!-- Clipboard clip -->
|
||||
<rect x="44" y="16" width="40" height="16" rx="4" fill="white"/>
|
||||
<rect x="52" y="20" width="24" height="8" rx="2" fill="#0078d4"/>
|
||||
<!-- Lines on clipboard -->
|
||||
<rect x="42" y="48" width="44" height="4" rx="2" fill="#0078d4" opacity="0.6"/>
|
||||
<rect x="42" y="60" width="36" height="4" rx="2" fill="#0078d4" opacity="0.4"/>
|
||||
<rect x="42" y="72" width="40" height="4" rx="2" fill="#0078d4" opacity="0.3"/>
|
||||
<rect x="42" y="84" width="28" height="4" rx="2" fill="#0078d4" opacity="0.2"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1017 B |
288
src-tauri/src/clipboard_manager.rs
Normal file
288
src-tauri/src/clipboard_manager.rs
Normal file
@@ -0,0 +1,288 @@
|
||||
//! Clipboard Manager Module
|
||||
//! Handles clipboard monitoring, history storage, and paste injection
|
||||
|
||||
use arboard::{Clipboard, ImageData};
|
||||
use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
|
||||
use chrono::{DateTime, Utc};
|
||||
use image::{DynamicImage, ImageFormat};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::io::Cursor;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Maximum number of items to store in history
|
||||
const MAX_HISTORY_SIZE: usize = 50;
|
||||
|
||||
/// Content type for clipboard items
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(tag = "type", content = "data")]
|
||||
pub enum ClipboardContent {
|
||||
/// Plain text content
|
||||
Text(String),
|
||||
/// Image as base64 encoded PNG
|
||||
Image {
|
||||
base64: String,
|
||||
width: u32,
|
||||
height: u32,
|
||||
},
|
||||
}
|
||||
|
||||
/// A single clipboard history item
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ClipboardItem {
|
||||
/// Unique identifier
|
||||
pub id: String,
|
||||
/// The content
|
||||
pub content: ClipboardContent,
|
||||
/// When it was copied
|
||||
pub timestamp: DateTime<Utc>,
|
||||
/// Whether this item is pinned
|
||||
pub pinned: bool,
|
||||
/// Preview text (for display)
|
||||
pub preview: String,
|
||||
}
|
||||
|
||||
impl ClipboardItem {
|
||||
/// Create a new text item
|
||||
pub fn new_text(text: String) -> Self {
|
||||
let preview = if text.len() > 100 {
|
||||
format!("{}...", &text[..100])
|
||||
} else {
|
||||
text.clone()
|
||||
};
|
||||
|
||||
Self {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
content: ClipboardContent::Text(text),
|
||||
timestamp: Utc::now(),
|
||||
pinned: false,
|
||||
preview,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new image item
|
||||
pub fn new_image(base64: String, width: u32, height: u32) -> Self {
|
||||
Self {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
content: ClipboardContent::Image {
|
||||
base64,
|
||||
width,
|
||||
height,
|
||||
},
|
||||
timestamp: Utc::now(),
|
||||
pinned: false,
|
||||
preview: format!("Image ({}x{})", width, height),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Manages clipboard operations and history
|
||||
pub struct ClipboardManager {
|
||||
history: Vec<ClipboardItem>,
|
||||
}
|
||||
|
||||
impl ClipboardManager {
|
||||
/// Create a new clipboard manager
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
history: Vec::with_capacity(MAX_HISTORY_SIZE),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a clipboard instance (creates new each time for thread safety)
|
||||
fn get_clipboard() -> Result<Clipboard, arboard::Error> {
|
||||
Clipboard::new()
|
||||
}
|
||||
|
||||
/// Get current text from clipboard
|
||||
pub fn get_current_text(&mut self) -> Result<String, arboard::Error> {
|
||||
Self::get_clipboard()?.get_text()
|
||||
}
|
||||
|
||||
/// Get current image from clipboard with hash for change detection
|
||||
pub fn get_current_image(
|
||||
&mut self,
|
||||
) -> Result<Option<(ImageData<'static>, u64)>, arboard::Error> {
|
||||
let mut clipboard = Self::get_clipboard()?;
|
||||
match clipboard.get_image() {
|
||||
Ok(image) => {
|
||||
// Create hash from image data for comparison
|
||||
let mut hasher = DefaultHasher::new();
|
||||
image.bytes.hash(&mut hasher);
|
||||
let hash = hasher.finish();
|
||||
|
||||
// Convert to owned data
|
||||
let owned = ImageData {
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
bytes: image.bytes.into_owned().into(),
|
||||
};
|
||||
|
||||
Ok(Some((owned, hash)))
|
||||
}
|
||||
Err(arboard::Error::ContentNotAvailable) => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Add text to history
|
||||
pub fn add_text(&mut self, text: String) -> Option<ClipboardItem> {
|
||||
// Don't add empty strings or duplicates
|
||||
if text.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Check for duplicates (non-pinned items only)
|
||||
if let Some(pos) = self.history.iter().position(|item| {
|
||||
!item.pinned && matches!(&item.content, ClipboardContent::Text(t) if t == &text)
|
||||
}) {
|
||||
// Remove the duplicate and add to top
|
||||
self.history.remove(pos);
|
||||
}
|
||||
|
||||
let item = ClipboardItem::new_text(text);
|
||||
self.insert_item(item.clone());
|
||||
Some(item)
|
||||
}
|
||||
|
||||
/// Add image to history
|
||||
pub fn add_image(&mut self, image_data: ImageData<'_>) -> Option<ClipboardItem> {
|
||||
// Convert to base64 PNG
|
||||
let img = DynamicImage::ImageRgba8(
|
||||
image::RgbaImage::from_raw(
|
||||
image_data.width as u32,
|
||||
image_data.height as u32,
|
||||
image_data.bytes.to_vec(),
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
|
||||
let mut buffer = Cursor::new(Vec::new());
|
||||
if img.write_to(&mut buffer, ImageFormat::Png).is_err() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let base64 = BASE64.encode(buffer.get_ref());
|
||||
let item =
|
||||
ClipboardItem::new_image(base64, image_data.width as u32, image_data.height as u32);
|
||||
|
||||
self.insert_item(item.clone());
|
||||
Some(item)
|
||||
}
|
||||
|
||||
/// Insert an item at the top of history (respecting pinned items)
|
||||
fn insert_item(&mut self, item: ClipboardItem) {
|
||||
// Find the first non-pinned position
|
||||
let insert_pos = self.history.iter().position(|i| !i.pinned).unwrap_or(0);
|
||||
self.history.insert(insert_pos, item);
|
||||
|
||||
// Trim to max size (remove from end, but preserve pinned items)
|
||||
while self.history.len() > MAX_HISTORY_SIZE {
|
||||
if let Some(pos) = self.history.iter().rposition(|i| !i.pinned) {
|
||||
self.history.remove(pos);
|
||||
} else {
|
||||
break; // All items are pinned, don't remove any
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the full history
|
||||
pub fn get_history(&self) -> Vec<ClipboardItem> {
|
||||
self.history.clone()
|
||||
}
|
||||
|
||||
/// Get a specific item by ID
|
||||
pub fn get_item(&self, id: &str) -> Option<&ClipboardItem> {
|
||||
self.history.iter().find(|item| item.id == id)
|
||||
}
|
||||
|
||||
/// Clear all non-pinned history
|
||||
pub fn clear(&mut self) {
|
||||
self.history.retain(|item| item.pinned);
|
||||
}
|
||||
|
||||
/// Remove a specific item
|
||||
pub fn remove_item(&mut self, id: &str) {
|
||||
self.history.retain(|item| item.id != id);
|
||||
}
|
||||
|
||||
/// Toggle pin status
|
||||
pub fn toggle_pin(&mut self, id: &str) -> Option<ClipboardItem> {
|
||||
if let Some(item) = self.history.iter_mut().find(|i| i.id == id) {
|
||||
item.pinned = !item.pinned;
|
||||
return Some(item.clone());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Paste an item (write to clipboard and simulate Ctrl+V)
|
||||
pub fn paste_item(&self, item: &ClipboardItem) -> Result<(), String> {
|
||||
// Create a new clipboard instance for pasting
|
||||
let mut clipboard = Self::get_clipboard().map_err(|e| e.to_string())?;
|
||||
|
||||
match &item.content {
|
||||
ClipboardContent::Text(text) => {
|
||||
clipboard.set_text(text).map_err(|e| e.to_string())?;
|
||||
}
|
||||
ClipboardContent::Image {
|
||||
base64,
|
||||
width,
|
||||
height,
|
||||
} => {
|
||||
let bytes = BASE64.decode(base64).map_err(|e| e.to_string())?;
|
||||
let img = image::load_from_memory(&bytes).map_err(|e| e.to_string())?;
|
||||
let rgba = img.to_rgba8();
|
||||
|
||||
let image_data = ImageData {
|
||||
width: *width as usize,
|
||||
height: *height as usize,
|
||||
bytes: rgba.into_raw().into(),
|
||||
};
|
||||
|
||||
clipboard.set_image(image_data).map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
|
||||
// Simulate Ctrl+V to paste
|
||||
simulate_paste()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Simulate Ctrl+V keypress for paste injection
|
||||
#[cfg(target_os = "linux")]
|
||||
fn simulate_paste() -> Result<(), String> {
|
||||
use enigo::{Direction, Enigo, Key, Keyboard, Settings};
|
||||
|
||||
// Small delay to ensure clipboard is ready
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
|
||||
let mut enigo = Enigo::new(&Settings::default()).map_err(|e| e.to_string())?;
|
||||
|
||||
// Press Ctrl+V
|
||||
enigo
|
||||
.key(Key::Control, Direction::Press)
|
||||
.map_err(|e| e.to_string())?;
|
||||
enigo
|
||||
.key(Key::Unicode('v'), Direction::Click)
|
||||
.map_err(|e| e.to_string())?;
|
||||
enigo
|
||||
.key(Key::Control, Direction::Release)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn simulate_paste() -> Result<(), String> {
|
||||
// Fallback for other platforms - just set clipboard
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl Default for ClipboardManager {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
108
src-tauri/src/hotkey_manager.rs
Normal file
108
src-tauri/src/hotkey_manager.rs
Normal file
@@ -0,0 +1,108 @@
|
||||
//! Global Hotkey Manager Module
|
||||
//! Handles global keyboard shortcuts using rdev
|
||||
|
||||
use rdev::{listen, Event, EventType, Key};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::thread::{self, JoinHandle};
|
||||
|
||||
/// Manages global hotkey listening
|
||||
pub struct HotkeyManager {
|
||||
running: Arc<AtomicBool>,
|
||||
_handle: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl HotkeyManager {
|
||||
/// Create a new hotkey manager with a callback for when the hotkey is pressed
|
||||
pub fn new<F>(callback: F) -> Self
|
||||
where
|
||||
F: Fn() + Send + Sync + 'static,
|
||||
{
|
||||
let running = Arc::new(AtomicBool::new(true));
|
||||
let running_clone = running.clone();
|
||||
let callback = Arc::new(callback);
|
||||
|
||||
let handle = thread::spawn(move || {
|
||||
// Use atomic bools for thread-safe state tracking
|
||||
let super_pressed = Arc::new(AtomicBool::new(false));
|
||||
let ctrl_pressed = Arc::new(AtomicBool::new(false));
|
||||
let alt_pressed = Arc::new(AtomicBool::new(false));
|
||||
|
||||
let super_clone = super_pressed.clone();
|
||||
let ctrl_clone = ctrl_pressed.clone();
|
||||
let alt_clone = alt_pressed.clone();
|
||||
let callback_clone = callback.clone();
|
||||
let running_inner = running_clone.clone();
|
||||
|
||||
// Use listen for better compatibility (doesn't require special permissions)
|
||||
let result = listen(move |event: Event| {
|
||||
if !running_inner.load(Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
|
||||
match event.event_type {
|
||||
EventType::KeyPress(key) => {
|
||||
match key {
|
||||
Key::MetaLeft | Key::MetaRight => {
|
||||
super_clone.store(true, Ordering::SeqCst);
|
||||
}
|
||||
Key::ControlLeft | Key::ControlRight => {
|
||||
ctrl_clone.store(true, Ordering::SeqCst);
|
||||
}
|
||||
Key::Alt | Key::AltGr => {
|
||||
alt_clone.store(true, Ordering::SeqCst);
|
||||
}
|
||||
Key::KeyV => {
|
||||
// Check for Super+V (Windows-like) or Ctrl+Alt+V (fallback)
|
||||
let super_down = super_clone.load(Ordering::SeqCst);
|
||||
let ctrl_down = ctrl_clone.load(Ordering::SeqCst);
|
||||
let alt_down = alt_clone.load(Ordering::SeqCst);
|
||||
|
||||
if super_down || (ctrl_down && alt_down) {
|
||||
callback_clone();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
EventType::KeyRelease(key) => match key {
|
||||
Key::MetaLeft | Key::MetaRight => {
|
||||
super_clone.store(false, Ordering::SeqCst);
|
||||
}
|
||||
Key::ControlLeft | Key::ControlRight => {
|
||||
ctrl_clone.store(false, Ordering::SeqCst);
|
||||
}
|
||||
Key::Alt | Key::AltGr => {
|
||||
alt_clone.store(false, Ordering::SeqCst);
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
|
||||
if let Err(e) = result {
|
||||
eprintln!("Hotkey listener error: {:?}", e);
|
||||
eprintln!("Note: Global hotkeys may require the user to be in the 'input' group on Linux.");
|
||||
eprintln!("Run: sudo usermod -aG input $USER");
|
||||
}
|
||||
});
|
||||
|
||||
Self {
|
||||
running,
|
||||
_handle: Some(handle),
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop the hotkey listener
|
||||
#[allow(dead_code)]
|
||||
pub fn stop(&self) {
|
||||
self.running.store(false, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for HotkeyManager {
|
||||
fn drop(&mut self) {
|
||||
self.stop();
|
||||
}
|
||||
}
|
||||
8
src-tauri/src/lib.rs
Normal file
8
src-tauri/src/lib.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
//! Win11 Clipboard History Library
|
||||
//! This module re-exports the core functionality for use as a library
|
||||
|
||||
pub mod clipboard_manager;
|
||||
pub mod hotkey_manager;
|
||||
|
||||
pub use clipboard_manager::{ClipboardContent, ClipboardItem, ClipboardManager};
|
||||
pub use hotkey_manager::HotkeyManager;
|
||||
249
src-tauri/src/main.rs
Normal file
249
src-tauri/src/main.rs
Normal file
@@ -0,0 +1,249 @@
|
||||
// Prevents additional console window on Windows in release
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
use parking_lot::Mutex;
|
||||
use std::sync::Arc;
|
||||
use tauri::{
|
||||
image::Image,
|
||||
menu::{Menu, MenuItem},
|
||||
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
||||
AppHandle, Emitter, Manager, State, WebviewWindow,
|
||||
};
|
||||
use win11_clipboard_history_lib::clipboard_manager::{ClipboardItem, ClipboardManager};
|
||||
use win11_clipboard_history_lib::hotkey_manager::HotkeyManager;
|
||||
|
||||
/// Application state shared across all handlers
|
||||
pub struct AppState {
|
||||
clipboard_manager: Arc<Mutex<ClipboardManager>>,
|
||||
hotkey_manager: Arc<Mutex<Option<HotkeyManager>>>,
|
||||
}
|
||||
|
||||
/// Get clipboard history
|
||||
#[tauri::command]
|
||||
fn get_history(state: State<AppState>) -> Vec<ClipboardItem> {
|
||||
state.clipboard_manager.lock().get_history()
|
||||
}
|
||||
|
||||
/// Clear all clipboard history
|
||||
#[tauri::command]
|
||||
fn clear_history(state: State<AppState>) {
|
||||
state.clipboard_manager.lock().clear();
|
||||
}
|
||||
|
||||
/// Delete a specific item from history
|
||||
#[tauri::command]
|
||||
fn delete_item(state: State<AppState>, id: String) {
|
||||
state.clipboard_manager.lock().remove_item(&id);
|
||||
}
|
||||
|
||||
/// Pin/unpin an item
|
||||
#[tauri::command]
|
||||
fn toggle_pin(state: State<AppState>, id: String) -> Option<ClipboardItem> {
|
||||
state.clipboard_manager.lock().toggle_pin(&id)
|
||||
}
|
||||
|
||||
/// Paste an item from history
|
||||
#[tauri::command]
|
||||
async fn paste_item(app: AppHandle, state: State<'_, AppState>, id: String) -> Result<(), String> {
|
||||
// First hide the window
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.hide();
|
||||
}
|
||||
|
||||
// Get the item and paste it
|
||||
let item = {
|
||||
let manager = state.clipboard_manager.lock();
|
||||
manager.get_item(&id).cloned()
|
||||
};
|
||||
|
||||
if let Some(item) = item {
|
||||
// Small delay to ensure window is hidden and previous app has focus
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
|
||||
// Write to clipboard and simulate paste
|
||||
let manager = state.clipboard_manager.lock();
|
||||
manager
|
||||
.paste_item(&item)
|
||||
.map_err(|e| format!("Failed to paste: {}", e))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Show the clipboard window at cursor position
|
||||
fn show_window_at_cursor(window: &WebviewWindow) {
|
||||
use tauri::{PhysicalPosition, PhysicalSize};
|
||||
|
||||
// Get cursor position - fallback to center if not available
|
||||
let cursor_pos = window
|
||||
.cursor_position()
|
||||
.unwrap_or_else(|_| PhysicalPosition::new(100.0, 100.0));
|
||||
|
||||
// Get monitor info for bounds checking
|
||||
if let Ok(Some(monitor)) = window.current_monitor() {
|
||||
let monitor_size = monitor.size();
|
||||
let window_size = window.outer_size().unwrap_or(PhysicalSize::new(360, 480));
|
||||
|
||||
// Calculate position, keeping window within screen bounds
|
||||
let mut x = cursor_pos.x as i32;
|
||||
let mut y = cursor_pos.y as i32;
|
||||
|
||||
// Adjust if window would go off-screen
|
||||
if x + window_size.width as i32 > monitor_size.width as i32 {
|
||||
x = monitor_size.width as i32 - window_size.width as i32 - 10;
|
||||
}
|
||||
if y + window_size.height as i32 > monitor_size.height as i32 {
|
||||
y = monitor_size.height as i32 - window_size.height as i32 - 10;
|
||||
}
|
||||
|
||||
let _ = window.set_position(PhysicalPosition::new(x, y));
|
||||
}
|
||||
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
|
||||
/// Toggle window visibility
|
||||
fn toggle_window(app: &AppHandle) {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
if window.is_visible().unwrap_or(false) {
|
||||
let _ = window.hide();
|
||||
} else {
|
||||
show_window_at_cursor(&window);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Start clipboard monitoring in background thread
|
||||
fn start_clipboard_watcher(app: AppHandle, clipboard_manager: Arc<Mutex<ClipboardManager>>) {
|
||||
std::thread::spawn(move || {
|
||||
let mut last_text: Option<String> = None;
|
||||
let mut last_image_hash: Option<u64> = None;
|
||||
|
||||
loop {
|
||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||
|
||||
let mut manager = clipboard_manager.lock();
|
||||
|
||||
// Check for text changes
|
||||
if let Ok(text) = manager.get_current_text() {
|
||||
if Some(&text) != last_text.as_ref() && !text.is_empty() {
|
||||
last_text = Some(text.clone());
|
||||
if let Some(item) = manager.add_text(text) {
|
||||
// Emit event to frontend
|
||||
let _ = app.emit("clipboard-changed", &item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for image changes
|
||||
if let Ok(Some((image_data, hash))) = manager.get_current_image() {
|
||||
if Some(hash) != last_image_hash {
|
||||
last_image_hash = Some(hash);
|
||||
if let Some(item) = manager.add_image(image_data) {
|
||||
let _ = app.emit("clipboard-changed", &item);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Start global hotkey listener
|
||||
fn start_hotkey_listener(app: AppHandle) -> HotkeyManager {
|
||||
let app_clone = app.clone();
|
||||
|
||||
HotkeyManager::new(move || {
|
||||
toggle_window(&app_clone);
|
||||
})
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let clipboard_manager = Arc::new(Mutex::new(ClipboardManager::new()));
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.manage(AppState {
|
||||
clipboard_manager: clipboard_manager.clone(),
|
||||
hotkey_manager: Arc::new(Mutex::new(None)),
|
||||
})
|
||||
.setup(move |app| {
|
||||
let app_handle = app.handle().clone();
|
||||
|
||||
// Setup system tray
|
||||
let quit_item = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?;
|
||||
let show_item = MenuItem::with_id(app, "show", "Show Clipboard", true, None::<&str>)?;
|
||||
let clear_item = MenuItem::with_id(app, "clear", "Clear History", true, None::<&str>)?;
|
||||
|
||||
let menu = Menu::with_items(app, &[&show_item, &clear_item, &quit_item])?;
|
||||
|
||||
// Load tray icon
|
||||
let icon =
|
||||
Image::from_bytes(include_bytes!("../icons/icon.png")).unwrap_or_else(|_| {
|
||||
Image::from_bytes(include_bytes!("../icons/32x32.png")).unwrap()
|
||||
});
|
||||
|
||||
let _tray = TrayIconBuilder::new()
|
||||
.icon(icon)
|
||||
.menu(&menu)
|
||||
.tooltip("Clipboard History (Super+V)")
|
||||
.on_menu_event(move |app, event| match event.id.as_ref() {
|
||||
"quit" => {
|
||||
app.exit(0);
|
||||
}
|
||||
"show" => {
|
||||
toggle_window(app);
|
||||
}
|
||||
"clear" => {
|
||||
if let Some(state) = app.try_state::<AppState>() {
|
||||
state.clipboard_manager.lock().clear();
|
||||
let _ = app.emit("history-cleared", ());
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
})
|
||||
.on_tray_icon_event(|tray, event| {
|
||||
if let TrayIconEvent::Click {
|
||||
button: MouseButton::Left,
|
||||
button_state: MouseButtonState::Up,
|
||||
..
|
||||
} = event
|
||||
{
|
||||
toggle_window(tray.app_handle());
|
||||
}
|
||||
})
|
||||
.build(app)?;
|
||||
|
||||
// Setup window blur handler (close on focus loss)
|
||||
let main_window = app.get_webview_window("main").unwrap();
|
||||
let window_clone = main_window.clone();
|
||||
|
||||
main_window.on_window_event(move |event| {
|
||||
if let tauri::WindowEvent::Focused(false) = event {
|
||||
let _ = window_clone.hide();
|
||||
}
|
||||
});
|
||||
|
||||
// Start clipboard watcher
|
||||
start_clipboard_watcher(app_handle.clone(), clipboard_manager.clone());
|
||||
|
||||
// Start global hotkey listener
|
||||
let hotkey_manager = start_hotkey_listener(app_handle.clone());
|
||||
|
||||
// Store hotkey manager in state
|
||||
if let Some(state) = app_handle.try_state::<AppState>() {
|
||||
*state.hotkey_manager.lock() = Some(hotkey_manager);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
get_history,
|
||||
clear_history,
|
||||
delete_item,
|
||||
toggle_pin,
|
||||
paste_item,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
51
src-tauri/tauri.conf.json
Normal file
51
src-tauri/tauri.conf.json
Normal file
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "win11-clipboard-history",
|
||||
"version": "1.0.0",
|
||||
"identifier": "com.clipboard.win11history",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"withGlobalTauri": true,
|
||||
"trayIcon": {
|
||||
"iconPath": "icons/icon.png",
|
||||
"iconAsTemplate": true
|
||||
},
|
||||
"windows": [
|
||||
{
|
||||
"title": "Clipboard History",
|
||||
"label": "main",
|
||||
"width": 360,
|
||||
"height": 480,
|
||||
"resizable": false,
|
||||
"decorations": false,
|
||||
"transparent": true,
|
||||
"visible": false,
|
||||
"skipTaskbar": true,
|
||||
"alwaysOnTop": true,
|
||||
"focus": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": ["deb", "appimage", "rpm"],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"category": "Utility",
|
||||
"shortDescription": "Windows 11-style Clipboard History Manager",
|
||||
"longDescription": "A modern, beautiful clipboard history manager for Linux that mimics the Windows 11 Win+V clipboard experience. Built with Tauri, React, and Rust."
|
||||
}
|
||||
}
|
||||
117
src/App.tsx
Normal file
117
src/App.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import { clsx } from 'clsx'
|
||||
import { useClipboardHistory } from './hooks/useClipboardHistory'
|
||||
import { useDarkMode } from './hooks/useDarkMode'
|
||||
import { HistoryItem } from './components/HistoryItem'
|
||||
import { TabBar } from './components/TabBar'
|
||||
import { Header } from './components/Header'
|
||||
import { EmptyState } from './components/EmptyState'
|
||||
import type { ActiveTab } from './types/clipboard'
|
||||
|
||||
/**
|
||||
* Main App Component - Windows 11 Clipboard History Manager
|
||||
*/
|
||||
function App() {
|
||||
const [activeTab, setActiveTab] = useState<ActiveTab>('clipboard')
|
||||
const isDark = useDarkMode()
|
||||
|
||||
const { history, isLoading, clearHistory, deleteItem, togglePin, pasteItem } =
|
||||
useClipboardHistory()
|
||||
|
||||
// Handle tab change
|
||||
const handleTabChange = useCallback((tab: ActiveTab) => {
|
||||
setActiveTab(tab)
|
||||
}, [])
|
||||
|
||||
// Render content based on active tab
|
||||
const renderContent = () => {
|
||||
switch (activeTab) {
|
||||
case 'clipboard':
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="w-6 h-6 border-2 border-win11-bg-accent border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (history.length === 0) {
|
||||
return <EmptyState />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 p-3">
|
||||
{history.map((item, index) => (
|
||||
<HistoryItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
index={index}
|
||||
onPaste={pasteItem}
|
||||
onDelete={deleteItem}
|
||||
onTogglePin={togglePin}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'gifs':
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full py-12 px-4 text-center">
|
||||
<p className="text-sm dark:text-win11-text-secondary text-win11Light-text-secondary">
|
||||
GIF search coming soon! 🎬
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'emoji':
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full py-12 px-4 text-center">
|
||||
<p className="text-sm dark:text-win11-text-secondary text-win11Light-text-secondary">
|
||||
Emoji picker coming soon! 😊
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
// Container styles
|
||||
'h-screen w-screen overflow-hidden flex flex-col',
|
||||
'rounded-win11-lg',
|
||||
// Glassmorphism effect
|
||||
isDark ? 'glass-effect' : 'glass-effect-light',
|
||||
// Background fallback
|
||||
isDark ? 'bg-win11-acrylic-bg' : 'bg-win11Light-acrylic-bg',
|
||||
// Text color based on theme
|
||||
isDark ? 'text-win11-text-primary' : 'text-win11Light-text-primary'
|
||||
)}
|
||||
>
|
||||
{/* Header with title and actions */}
|
||||
<Header onClearHistory={clearHistory} itemCount={history.filter((i) => !i.pinned).length} />
|
||||
|
||||
{/* Tab bar */}
|
||||
<TabBar activeTab={activeTab} onTabChange={handleTabChange} />
|
||||
|
||||
{/* Scrollable content area */}
|
||||
<div className="flex-1 overflow-y-auto scrollbar-win11">{renderContent()}</div>
|
||||
|
||||
{/* Footer hint */}
|
||||
<div className="px-4 py-2 text-center border-t dark:border-win11-border-subtle border-win11Light-border">
|
||||
<p className="text-xs dark:text-win11-text-tertiary text-win11Light-text-secondary">
|
||||
Click an item to paste • Press{' '}
|
||||
<kbd className="px-1 py-0.5 rounded dark:bg-win11-bg-tertiary bg-win11Light-bg-tertiary font-mono">
|
||||
Esc
|
||||
</kbd>{' '}
|
||||
to close
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
26
src/components/EmptyState.tsx
Normal file
26
src/components/EmptyState.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { ClipboardList } from 'lucide-react'
|
||||
|
||||
/**
|
||||
* Empty state component when there's no clipboard history
|
||||
*/
|
||||
export function EmptyState() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full py-12 px-4 text-center">
|
||||
<div className="w-16 h-16 rounded-full dark:bg-win11-bg-tertiary bg-win11Light-bg-tertiary flex items-center justify-center mb-4">
|
||||
<ClipboardList className="w-8 h-8 dark:text-win11-text-tertiary text-win11Light-text-secondary" />
|
||||
</div>
|
||||
|
||||
<h3 className="text-base font-medium dark:text-win11-text-primary text-win11Light-text-primary mb-2">
|
||||
No clipboard history yet
|
||||
</h3>
|
||||
|
||||
<p className="text-sm dark:text-win11-text-secondary text-win11Light-text-secondary max-w-[200px]">
|
||||
Copy something to see it appear here. Press{' '}
|
||||
<kbd className="px-1.5 py-0.5 rounded dark:bg-win11-bg-tertiary bg-win11Light-bg-tertiary text-xs font-mono">
|
||||
Super+V
|
||||
</kbd>{' '}
|
||||
to open anytime.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
56
src/components/Header.tsx
Normal file
56
src/components/Header.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { Trash2, Settings } from 'lucide-react'
|
||||
import { clsx } from 'clsx'
|
||||
|
||||
interface HeaderProps {
|
||||
onClearHistory: () => void
|
||||
itemCount: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Header component with title and action buttons
|
||||
*/
|
||||
export function Header({ onClearHistory, itemCount }: HeaderProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between px-4 py-3" data-tauri-drag-region>
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-sm font-semibold dark:text-win11-text-primary text-win11Light-text-primary">
|
||||
Clipboard History
|
||||
</h1>
|
||||
{itemCount > 0 && (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full dark:bg-win11-bg-tertiary bg-win11Light-bg-tertiary dark:text-win11-text-secondary text-win11Light-text-secondary">
|
||||
{itemCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Clear history button */}
|
||||
<button
|
||||
onClick={onClearHistory}
|
||||
disabled={itemCount === 0}
|
||||
className={clsx(
|
||||
'p-2 rounded-md transition-colors',
|
||||
'dark:text-win11-text-secondary text-win11Light-text-secondary',
|
||||
'hover:dark:bg-win11-bg-tertiary hover:bg-win11Light-bg-tertiary',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
)}
|
||||
title="Clear all"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{/* Settings button (placeholder for future) */}
|
||||
<button
|
||||
className={clsx(
|
||||
'p-2 rounded-md transition-colors',
|
||||
'dark:text-win11-text-secondary text-win11Light-text-secondary',
|
||||
'hover:dark:bg-win11-bg-tertiary hover:bg-win11Light-bg-tertiary'
|
||||
)}
|
||||
title="Settings"
|
||||
>
|
||||
<Settings className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
171
src/components/HistoryItem.tsx
Normal file
171
src/components/HistoryItem.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import { useCallback } from 'react'
|
||||
import { clsx } from 'clsx'
|
||||
import { Pin, X, Image as ImageIcon, Type } from 'lucide-react'
|
||||
import type { ClipboardItem } from '../types/clipboard'
|
||||
|
||||
interface HistoryItemProps {
|
||||
item: ClipboardItem
|
||||
onPaste: (id: string) => void
|
||||
onDelete: (id: string) => void
|
||||
onTogglePin: (id: string) => void
|
||||
index: number
|
||||
}
|
||||
|
||||
/**
|
||||
* A single clipboard history card with Windows 11 styling
|
||||
*/
|
||||
export function HistoryItem({ item, onPaste, onDelete, onTogglePin, index }: HistoryItemProps) {
|
||||
const isText = item.content.type === 'Text'
|
||||
|
||||
// Format timestamp
|
||||
const formatTime = useCallback((timestamp: string) => {
|
||||
const date = new Date(timestamp)
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
const diffMins = Math.floor(diffMs / 60000)
|
||||
const diffHours = Math.floor(diffMs / 3600000)
|
||||
|
||||
if (diffMins < 1) return 'Just now'
|
||||
if (diffMins < 60) return `${diffMins}m ago`
|
||||
if (diffHours < 24) return `${diffHours}h ago`
|
||||
return date.toLocaleDateString()
|
||||
}, [])
|
||||
|
||||
// Handle paste on click
|
||||
const handleClick = useCallback(() => {
|
||||
onPaste(item.id)
|
||||
}, [item.id, onPaste])
|
||||
|
||||
// Handle delete with stopPropagation
|
||||
const handleDelete = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
onDelete(item.id)
|
||||
},
|
||||
[item.id, onDelete]
|
||||
)
|
||||
|
||||
// Handle pin toggle with stopPropagation
|
||||
const handleTogglePin = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
onTogglePin(item.id)
|
||||
},
|
||||
[item.id, onTogglePin]
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
// Base styles
|
||||
'group relative rounded-win11 p-3 cursor-pointer',
|
||||
'transition-all duration-150 ease-out',
|
||||
// Animation delay based on index
|
||||
'animate-in',
|
||||
// Dark mode styles
|
||||
'dark:bg-win11-bg-card dark:hover:bg-win11-bg-card-hover',
|
||||
'dark:border dark:border-win11-border-subtle',
|
||||
// Light mode styles
|
||||
'bg-win11Light-bg-card hover:bg-win11Light-bg-card-hover',
|
||||
'border border-win11Light-border',
|
||||
// Pinned indicator
|
||||
item.pinned && 'ring-1 ring-win11-bg-accent'
|
||||
)}
|
||||
onClick={handleClick}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
handleClick()
|
||||
}
|
||||
}}
|
||||
style={{ animationDelay: `${index * 30}ms` }}
|
||||
>
|
||||
{/* Content type indicator */}
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Icon */}
|
||||
<div
|
||||
className={clsx(
|
||||
'flex-shrink-0 w-8 h-8 rounded-md flex items-center justify-center',
|
||||
'dark:bg-win11-bg-tertiary bg-win11Light-bg-tertiary'
|
||||
)}
|
||||
>
|
||||
{isText ? (
|
||||
<Type className="w-4 h-4 dark:text-win11-text-secondary text-win11Light-text-secondary" />
|
||||
) : (
|
||||
<ImageIcon className="w-4 h-4 dark:text-win11-text-secondary text-win11Light-text-secondary" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{item.content.type === 'Text' && (
|
||||
<p className="text-sm dark:text-win11-text-primary text-win11Light-text-primary line-clamp-3 break-words whitespace-pre-wrap">
|
||||
{item.content.data}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{item.content.type === 'Image' && (
|
||||
<div className="relative">
|
||||
<img
|
||||
src={`data:image/png;base64,${item.content.data.base64}`}
|
||||
alt="Clipboard image"
|
||||
className="max-w-full max-h-24 rounded object-contain bg-black/10"
|
||||
/>
|
||||
<span className="absolute bottom-1 right-1 text-xs px-1.5 py-0.5 rounded bg-black/60 text-white">
|
||||
{item.content.data.width}×{item.content.data.height}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timestamp */}
|
||||
<span className="text-xs dark:text-win11-text-tertiary text-win11Light-text-secondary mt-1 block">
|
||||
{formatTime(item.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Action buttons - visible on hover */}
|
||||
<div
|
||||
className={clsx(
|
||||
'flex items-center gap-1 opacity-0 group-hover:opacity-100',
|
||||
'transition-opacity duration-150'
|
||||
)}
|
||||
>
|
||||
{/* Pin button */}
|
||||
<button
|
||||
onClick={handleTogglePin}
|
||||
className={clsx(
|
||||
'p-1.5 rounded-md transition-colors',
|
||||
'hover:dark:bg-win11-bg-tertiary hover:bg-win11Light-bg-tertiary',
|
||||
item.pinned
|
||||
? 'text-win11-bg-accent'
|
||||
: 'dark:text-win11-text-tertiary text-win11Light-text-secondary'
|
||||
)}
|
||||
title={item.pinned ? 'Unpin' : 'Pin'}
|
||||
>
|
||||
<Pin className="w-4 h-4" fill={item.pinned ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
|
||||
{/* Delete button */}
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className={clsx(
|
||||
'p-1.5 rounded-md transition-colors',
|
||||
'dark:text-win11-text-tertiary text-win11Light-text-secondary',
|
||||
'hover:text-win11-error hover:dark:bg-win11-bg-tertiary hover:bg-win11Light-bg-tertiary'
|
||||
)}
|
||||
title="Delete"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pinned badge */}
|
||||
{item.pinned && (
|
||||
<div className="absolute -top-1 -right-1 w-2 h-2 rounded-full bg-win11-bg-accent" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
52
src/components/TabBar.tsx
Normal file
52
src/components/TabBar.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { clsx } from 'clsx'
|
||||
import { ClipboardList, Smile, Image } from 'lucide-react'
|
||||
import type { ActiveTab } from '../types/clipboard'
|
||||
|
||||
interface TabBarProps {
|
||||
activeTab: ActiveTab
|
||||
onTabChange: (tab: ActiveTab) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Windows 11 style tab bar for switching between Clipboard, GIFs, and Emoji
|
||||
*/
|
||||
export function TabBar({ activeTab, onTabChange }: TabBarProps) {
|
||||
const tabs: { id: ActiveTab; label: string; icon: typeof ClipboardList }[] = [
|
||||
{ id: 'clipboard', label: 'Clipboard', icon: ClipboardList },
|
||||
{ id: 'gifs', label: 'GIFs', icon: Image },
|
||||
{ id: 'emoji', label: 'Emoji', icon: Smile },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 p-2 border-b dark:border-win11-border-subtle border-win11Light-border">
|
||||
{tabs.map((tab) => {
|
||||
const Icon = tab.icon
|
||||
const isActive = activeTab === tab.id
|
||||
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => onTabChange(tab.id)}
|
||||
className={clsx(
|
||||
'flex items-center justify-center gap-2 px-4 py-2 rounded-md',
|
||||
'text-sm font-medium transition-all duration-150',
|
||||
'focus:outline-none focus-visible:ring-2 focus-visible:ring-win11-bg-accent',
|
||||
isActive
|
||||
? [
|
||||
'dark:bg-win11-bg-tertiary bg-win11Light-bg-tertiary',
|
||||
'dark:text-win11-text-primary text-win11Light-text-primary',
|
||||
]
|
||||
: [
|
||||
'dark:text-win11-text-secondary text-win11Light-text-secondary',
|
||||
'hover:dark:bg-win11-bg-card-hover hover:bg-win11Light-bg-card-hover',
|
||||
]
|
||||
)}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">{tab.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
109
src/hooks/useClipboardHistory.ts
Normal file
109
src/hooks/useClipboardHistory.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { listen, UnlistenFn } from '@tauri-apps/api/event'
|
||||
import type { ClipboardItem } from '../types/clipboard'
|
||||
|
||||
/**
|
||||
* Hook for managing clipboard history
|
||||
*/
|
||||
export function useClipboardHistory() {
|
||||
const [history, setHistory] = useState<ClipboardItem[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Fetch initial history
|
||||
const fetchHistory = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
const items = await invoke<ClipboardItem[]>('get_history')
|
||||
setHistory(items)
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch history')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Clear all history
|
||||
const clearHistory = useCallback(async () => {
|
||||
try {
|
||||
await invoke('clear_history')
|
||||
setHistory((prev) => prev.filter((item) => item.pinned))
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to clear history')
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Delete a specific item
|
||||
const deleteItem = useCallback(async (id: string) => {
|
||||
try {
|
||||
await invoke('delete_item', { id })
|
||||
setHistory((prev) => prev.filter((item) => item.id !== id))
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to delete item')
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Toggle pin status
|
||||
const togglePin = useCallback(async (id: string) => {
|
||||
try {
|
||||
const updatedItem = await invoke<ClipboardItem>('toggle_pin', { id })
|
||||
if (updatedItem) {
|
||||
setHistory((prev) => prev.map((item) => (item.id === id ? updatedItem : item)))
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to toggle pin')
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Paste an item
|
||||
const pasteItem = useCallback(async (id: string) => {
|
||||
try {
|
||||
await invoke('paste_item', { id })
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to paste item')
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Listen for clipboard changes
|
||||
useEffect(() => {
|
||||
fetchHistory()
|
||||
|
||||
let unlistenChanged: UnlistenFn | undefined
|
||||
let unlistenCleared: UnlistenFn | undefined
|
||||
|
||||
const setupListeners = async () => {
|
||||
unlistenChanged = await listen<ClipboardItem>('clipboard-changed', (event) => {
|
||||
setHistory((prev) => {
|
||||
// Add new item at the top (after pinned items)
|
||||
const pinnedItems = prev.filter((i) => i.pinned)
|
||||
const unpinnedItems = prev.filter((i) => !i.pinned)
|
||||
return [...pinnedItems, event.payload, ...unpinnedItems.slice(0, 49)]
|
||||
})
|
||||
})
|
||||
|
||||
unlistenCleared = await listen('history-cleared', () => {
|
||||
setHistory((prev) => prev.filter((item) => item.pinned))
|
||||
})
|
||||
}
|
||||
|
||||
setupListeners()
|
||||
|
||||
return () => {
|
||||
unlistenChanged?.()
|
||||
unlistenCleared?.()
|
||||
}
|
||||
}, [fetchHistory])
|
||||
|
||||
return {
|
||||
history,
|
||||
isLoading,
|
||||
error,
|
||||
fetchHistory,
|
||||
clearHistory,
|
||||
deleteItem,
|
||||
togglePin,
|
||||
pasteItem,
|
||||
}
|
||||
}
|
||||
30
src/hooks/useDarkMode.ts
Normal file
30
src/hooks/useDarkMode.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
/**
|
||||
* Hook for detecting system dark mode preference
|
||||
*/
|
||||
export function useDarkMode(): boolean {
|
||||
const [isDark, setIsDark] = useState(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
}
|
||||
return true // Default to dark mode
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
|
||||
const handleChange = (e: MediaQueryListEvent) => {
|
||||
setIsDark(e.matches)
|
||||
}
|
||||
|
||||
// Modern browsers
|
||||
mediaQuery.addEventListener('change', handleChange)
|
||||
|
||||
return () => {
|
||||
mediaQuery.removeEventListener('change', handleChange)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return isDark
|
||||
}
|
||||
154
src/index.css
Normal file
154
src/index.css
Normal file
@@ -0,0 +1,154 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Base styles */
|
||||
:root {
|
||||
font-family:
|
||||
'Segoe UI Variable',
|
||||
'Segoe UI',
|
||||
system-ui,
|
||||
-apple-system,
|
||||
sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
font-weight: 400;
|
||||
color-scheme: light dark;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* Reset for Tauri */
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Transparent background for Tauri window */
|
||||
body {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Make the app draggable from certain areas */
|
||||
[data-tauri-drag-region] {
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
/* Disable text selection */
|
||||
.no-select {
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Custom scrollbar for Windows 11 style */
|
||||
@layer components {
|
||||
.scrollbar-win11 {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255, 255, 255, 0.2) transparent;
|
||||
}
|
||||
|
||||
.scrollbar-win11::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.scrollbar-win11::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.scrollbar-win11::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.scrollbar-win11::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
/* Light mode scrollbar */
|
||||
@media (prefers-color-scheme: light) {
|
||||
.scrollbar-win11 {
|
||||
scrollbar-color: rgba(0, 0, 0, 0.2) transparent;
|
||||
}
|
||||
|
||||
.scrollbar-win11::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.scrollbar-win11::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Glassmorphism / Acrylic effect */
|
||||
@layer components {
|
||||
.glass-effect {
|
||||
@apply backdrop-blur-acrylic;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(255, 255, 255, 0.05) 0%,
|
||||
rgba(255, 255, 255, 0.02) 100%
|
||||
);
|
||||
box-shadow:
|
||||
0 8px 32px rgba(0, 0, 0, 0.25),
|
||||
inset 0 0 0 1px rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.glass-effect-light {
|
||||
@apply backdrop-blur-acrylic;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(255, 255, 255, 0.85) 0%,
|
||||
rgba(255, 255, 255, 0.75) 100%
|
||||
);
|
||||
box-shadow:
|
||||
0 8px 32px rgba(0, 0, 0, 0.15),
|
||||
inset 0 0 0 1px rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
/* Card hover effect */
|
||||
.card-hover {
|
||||
@apply transition-all duration-150 ease-out;
|
||||
}
|
||||
|
||||
.card-hover:hover {
|
||||
@apply scale-[1.01];
|
||||
}
|
||||
|
||||
.card-hover:active {
|
||||
@apply scale-[0.99];
|
||||
}
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@layer utilities {
|
||||
.animate-in {
|
||||
animation: fadeSlideIn 0.2s ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes fadeSlideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Focus styles for accessibility */
|
||||
@layer components {
|
||||
.focus-ring {
|
||||
@apply outline-none ring-2 ring-win11-bg-accent ring-offset-2 ring-offset-transparent;
|
||||
}
|
||||
}
|
||||
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
)
|
||||
36
src/types/clipboard.ts
Normal file
36
src/types/clipboard.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/** Clipboard content types */
|
||||
export type ClipboardContentType = 'text' | 'image'
|
||||
|
||||
/** Text content */
|
||||
export interface TextContent {
|
||||
type: 'Text'
|
||||
data: string
|
||||
}
|
||||
|
||||
/** Image content */
|
||||
export interface ImageContent {
|
||||
type: 'Image'
|
||||
data: {
|
||||
base64: string
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
}
|
||||
|
||||
/** Union of all content types */
|
||||
export type ClipboardContent = TextContent | ImageContent
|
||||
|
||||
/** A single clipboard history item */
|
||||
export interface ClipboardItem {
|
||||
id: string
|
||||
content: ClipboardContent
|
||||
timestamp: string
|
||||
pinned: boolean
|
||||
preview: string
|
||||
}
|
||||
|
||||
/** Active tab in the UI */
|
||||
export type ActiveTab = 'clipboard' | 'gifs' | 'emoji'
|
||||
|
||||
/** Theme mode */
|
||||
export type ThemeMode = 'light' | 'dark' | 'system'
|
||||
9
src/vite-env.d.ts
vendored
Normal file
9
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_APP_TITLE: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
92
tailwind.config.js
Normal file
92
tailwind.config.js
Normal file
@@ -0,0 +1,92 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
darkMode: 'media', // Use system preference
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
// Windows 11 Dark Theme Colors
|
||||
win11: {
|
||||
// Backgrounds
|
||||
'bg-primary': '#202020',
|
||||
'bg-secondary': '#2d2d2d',
|
||||
'bg-tertiary': '#383838',
|
||||
'bg-card': '#2d2d2d',
|
||||
'bg-card-hover': '#3d3d3d',
|
||||
'bg-accent': '#0078d4',
|
||||
'bg-accent-hover': '#1a86d9',
|
||||
|
||||
// Text
|
||||
'text-primary': '#ffffff',
|
||||
'text-secondary': '#c5c5c5',
|
||||
'text-tertiary': '#9e9e9e',
|
||||
'text-disabled': '#6e6e6e',
|
||||
|
||||
// Borders
|
||||
'border': '#454545',
|
||||
'border-subtle': '#3a3a3a',
|
||||
|
||||
// Acrylic/Mica effect
|
||||
'acrylic-bg': 'rgba(32, 32, 32, 0.7)',
|
||||
'acrylic-tint': 'rgba(255, 255, 255, 0.03)',
|
||||
|
||||
// Semantic colors
|
||||
'success': '#6ccb5f',
|
||||
'warning': '#fcb900',
|
||||
'error': '#ff5f5f',
|
||||
'info': '#0078d4',
|
||||
},
|
||||
// Light mode Windows 11 colors
|
||||
win11Light: {
|
||||
'bg-primary': '#f3f3f3',
|
||||
'bg-secondary': '#ffffff',
|
||||
'bg-tertiary': '#e5e5e5',
|
||||
'bg-card': '#ffffff',
|
||||
'bg-card-hover': '#f5f5f5',
|
||||
'text-primary': '#1a1a1a',
|
||||
'text-secondary': '#5c5c5c',
|
||||
'border': '#e5e5e5',
|
||||
'acrylic-bg': 'rgba(243, 243, 243, 0.7)',
|
||||
}
|
||||
},
|
||||
borderRadius: {
|
||||
'win11': '8px',
|
||||
'win11-lg': '12px',
|
||||
},
|
||||
boxShadow: {
|
||||
'win11': '0 8px 32px rgba(0, 0, 0, 0.25)',
|
||||
'win11-card': '0 2px 8px rgba(0, 0, 0, 0.15)',
|
||||
'win11-elevated': '0 16px 48px rgba(0, 0, 0, 0.35)',
|
||||
},
|
||||
backdropBlur: {
|
||||
'acrylic': '20px',
|
||||
},
|
||||
animation: {
|
||||
'fade-in': 'fadeIn 0.15s ease-out',
|
||||
'slide-up': 'slideUp 0.2s ease-out',
|
||||
'scale-in': 'scaleIn 0.15s ease-out',
|
||||
},
|
||||
keyframes: {
|
||||
fadeIn: {
|
||||
'0%': { opacity: '0' },
|
||||
'100%': { opacity: '1' },
|
||||
},
|
||||
slideUp: {
|
||||
'0%': { opacity: '0', transform: 'translateY(8px)' },
|
||||
'100%': { opacity: '1', transform: 'translateY(0)' },
|
||||
},
|
||||
scaleIn: {
|
||||
'0%': { opacity: '0', transform: 'scale(0.95)' },
|
||||
'100%': { opacity: '1', transform: 'scale(1)' },
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
'segoe': ['"Segoe UI Variable"', '"Segoe UI"', 'system-ui', 'sans-serif'],
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
31
tsconfig.json
Normal file
31
tsconfig.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
|
||||
/* Path mapping */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
11
tsconfig.node.json
Normal file
11
tsconfig.node.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
32
vite.config.ts
Normal file
32
vite.config.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
|
||||
// Tauri expects a fixed port for development
|
||||
server: {
|
||||
port: 1420,
|
||||
strictPort: true,
|
||||
watch: {
|
||||
// Workaround for WSL on Windows
|
||||
usePolling: true,
|
||||
},
|
||||
},
|
||||
|
||||
// Clear screen during dev
|
||||
clearScreen: false,
|
||||
|
||||
// Environment variables prefix
|
||||
envPrefix: ['VITE_', 'TAURI_'],
|
||||
|
||||
build: {
|
||||
// Tauri uses Chromium on Windows and WebKit on macOS and Linux
|
||||
target: process.env.TAURI_PLATFORM === 'windows' ? 'chrome105' : 'safari13',
|
||||
// Don't minify for debug builds
|
||||
minify: !process.env.TAURI_DEBUG ? 'esbuild' : false,
|
||||
// Produce sourcemaps for debug builds
|
||||
sourcemap: !!process.env.TAURI_DEBUG,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user