从零实现Electron屏幕共享:基于WebRTC的跨平台解决方案

从零实现Electron屏幕共享:基于WebRTC的跨平台解决方案

【免费下载链接】electron-quick-start Clone to try a simple Electron app 【免费下载链接】electron-quick-start 项目地址: https://gitcode.com/gh_mirrors/el/electron-quick-start

你是否在开发Electron应用时遇到屏幕共享难题?WebRTC API调用复杂?跨平台兼容性差?安全权限处理繁琐?本文将基于gh_mirrors/el/electron-quick-start项目,提供一套完整的屏幕共享实现方案,从基础架构到高级优化,助你快速掌握这一关键技术。

读完本文你将获得:

  • 3种屏幕捕获模式的完整实现代码
  • 主进程与渲染进程通信的安全配置
  • 跨平台兼容性处理的最佳实践
  • 10个性能优化技巧与常见问题解决方案

一、Electron屏幕共享技术架构

Electron结合Chromium的WebRTC能力与Node.js的系统访问权限,为屏幕共享提供了独特优势。理解其技术架构是实现高质量共享的基础。

1.1 核心技术栈

mermaid

1.2 进程间通信模型

Electron的多进程架构要求屏幕共享功能在主进程与渲染进程间协同工作:

mermaid

1.3 三种捕获模式对比

捕获模式实现难度适用场景性能消耗跨平台支持
整个屏幕★☆☆☆☆全屏演示Windows/macOS/Linux
应用窗口★★☆☆☆应用演示Windows/macOS
自定义区域★★★☆☆精确区域共享全平台

二、基础屏幕捕获实现

2.1 配置Preload脚本(安全通信层)

首先修改preload.js,建立主进程与渲染进程的安全通信通道:

const { contextBridge, ipcRenderer } = require('electron');

// 暴露屏幕共享API到渲染进程
contextBridge.exposeInMainWorld('screenShareAPI', {
  getSources: () => ipcRenderer.invoke('screen-share:get-sources'),
  startCapture: (sourceId) => ipcRenderer.invoke('screen-share:start-capture', sourceId),
  stopCapture: () => ipcRenderer.invoke('screen-share:stop-capture')
});

// 原有代码保持不变
window.addEventListener('DOMContentLoaded', () => {
  const replaceText = (selector, text) => {
    const element = document.getElementById(selector)
    if (element) element.innerText = text
  }

  for (const type of ['chrome', 'node', 'electron']) {
    replaceText(`${type}-version`, process.versions[type])
  }
});

2.2 实现主进程捕获逻辑

修改main.js,添加屏幕捕获相关的主进程代码:

const { app, BrowserWindow, ipcMain, desktopCapturer } = require('electron');
const path = require('node:path');

let mainWindow;
let mediaStream = null;

// 获取可用的屏幕/窗口源
ipcMain.handle('screen-share:get-sources', async () => {
  try {
    const sources = await desktopCapturer.getSources({
      types: ['window', 'screen'],
      thumbnailSize: { width: 1280, height: 720 }
    });
    
    return sources.map(source => ({
      id: source.id,
      name: source.name,
      thumbnailURL: source.thumbnail.toDataURL(),
      displayId: source.displayId
    }));
  } catch (error) {
    console.error('获取捕获源失败:', error);
    throw error;
  }
});

// 开始屏幕捕获
ipcMain.handle('screen-share:start-capture', async (event, sourceId) => {
  try {
    // 停止已有捕获
    if (mediaStream) {
      mediaStream.getTracks().forEach(track => track.stop());
    }

    // 获取媒体流
    mediaStream = await navigator.mediaDevices.getUserMedia({
      audio: false,
      video: {
        mandatory: {
          chromeMediaSource: 'desktop',
          chromeMediaSourceId: sourceId,
          minWidth: 1280,
          maxWidth: 1920,
          minHeight: 720,
          maxHeight: 1080
        }
      }
    });

    // 返回流ID供渲染进程使用
    return mediaStream.id;
  } catch (error) {
    console.error('开始捕获失败:', error);
    throw error;
  }
});

// 停止屏幕捕获
ipcMain.handle('screen-share:stop-capture', async () => {
  if (mediaStream) {
    mediaStream.getTracks().forEach(track => track.stop());
    mediaStream = null;
  }
  return true;
});

// 保持原有的createWindow函数和窗口配置
function createWindow () {
  mainWindow = new BrowserWindow({
    width: 800,
    height: 600,
    webPreferences: {
      preload: path.join(__dirname, 'preload.js'),
      // 启用必要的功能
      nodeIntegration: false,
      contextIsolation: true,
      enableRemoteModule: false
    }
  });

  mainWindow.loadFile('index.html');
}

// ... 保持其他原有代码不变

2.3 修改HTML页面(添加UI元素)

更新index.html,添加屏幕共享所需的UI组件:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <meta http-equiv="Content-Security-Policy" content="default-src self; script-src self; style-src self unsafe-inline">
    <link href="./styles.css" rel="stylesheet">
    <title>Electron屏幕共享演示</title>
  </head>
  <body>
    <h1>Electron WebRTC屏幕共享</h1>
    
    <!-- 屏幕共享控制区 -->
    <div class="share-controls">
      <button id="selectSourceBtn">选择共享源</button>
      <button id="startShareBtn" disabled>开始共享</button>
      <button id="stopShareBtn" disabled>停止共享</button>
    </div>
    
    <!-- 共享源选择列表 -->
    <div id="sourceList" class="source-list"></div>
    
    <!-- 共享预览区 -->
    <div class="preview-container">
      <h3>共享预览</h3>
      <video id="sharePreview" autoplay muted class="preview-video"></video>
    </div>

    <script src="./renderer.js"></script>
  </body>
</html>

2.4 添加样式表(styles.css)

/* 共享控制区样式 */
.share-controls {
  margin: 20px 0;
  display: flex;
  gap: 10px;
}

button {
  padding: 8px 16px;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 14px;
}

#startShareBtn {
  background-color: #4CAF50;
  color: white;
}

#stopShareBtn {
  background-color: #f44336;
  color: white;
}

#selectSourceBtn {
  background-color: #2196F3;
  color: white;
}

/* 共享源列表样式 */
.source-list {
  display: flex;
  flex-wrap: wrap;
  gap: 15px;
  margin: 20px 0;
  max-height: 200px;
  overflow-y: auto;
  padding: 10px;
  border: 1px solid #ccc;
  border-radius: 4px;
}

.source-item {
  width: 200px;
  border: 1px solid #ddd;
  border-radius: 4px;
  padding: 10px;
  cursor: pointer;
  transition: all 0.2s;
}

.source-item.selected {
  border-color: #2196F3;
  background-color: #e3f2fd;
}

.source-item img {
  width: 100%;
  height: 120px;
  object-fit: cover;
}

.source-item p {
  margin: 10px 0 0;
  font-size: 14px;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* 预览区样式 */
.preview-container {
  margin-top: 20px;
}

.preview-video {
  width: 100%;
  max-width: 800px;
  border: 1px solid #ccc;
  border-radius: 4px;
}

2.5 实现渲染进程逻辑(renderer.js)

document.addEventListener('DOMContentLoaded', () => {
  // DOM元素
  const selectSourceBtn = document.getElementById('selectSourceBtn');
  const startShareBtn = document.getElementById('startShareBtn');
  const stopShareBtn = document.getElementById('stopShareBtn');
  const sourceList = document.getElementById('sourceList');
  const sharePreview = document.getElementById('sharePreview');
  
  // 状态变量
  let selectedSourceId = null;
  let mediaStream = null;
  
  // 选择共享源
  selectSourceBtn.addEventListener('click', async () => {
    try {
      // 从主进程获取可用的共享源
      const sources = await window.screenShareAPI.getSources();
      
      // 清空列表并添加新源
      sourceList.innerHTML = '';
      
      sources.forEach(source => {
        const sourceItem = document.createElement('div');
        sourceItem.className = 'source-item';
        sourceItem.innerHTML = `
          <img src="${source.thumbnailURL}" alt="${source.name}">
          <p>${source.name}</p>
        `;
        
        // 点击选择源
        sourceItem.addEventListener('click', () => {
          // 移除其他项的选中状态
          document.querySelectorAll('.source-item').forEach(item => 
            item.classList.remove('selected'));
          // 添加当前项选中状态
          sourceItem.classList.add('selected');
          selectedSourceId = source.id;
          startShareBtn.disabled = false;
        });
        
        sourceList.appendChild(sourceItem);
      });
    } catch (error) {
      console.error('获取共享源失败:', error);
      alert(`获取共享源失败: ${error.message}`);
    }
  });
  
  // 开始共享
  startShareBtn.addEventListener('click', async () => {
    if (!selectedSourceId) return;
    
    try {
      // 开始捕获并获取媒体流
      const streamId = await window.screenShareAPI.startCapture(selectedSourceId);
      
      // 获取媒体流(实际项目中可能需要通过WebRTC传输到其他端)
      // 这里简化处理,直接显示在本地预览
      mediaStream = new MediaStream();
      
      // 在实际应用中,这里应该通过RTCPeerConnection发送流
      // 这里为简化演示,直接使用一个模拟的流播放
      // 注意:真实场景下需要通过WebRTC建立对等连接
      sharePreview.srcObject = mediaStream;
      
      // 更新UI状态
      startShareBtn.disabled = true;
      stopShareBtn.disabled = false;
      selectSourceBtn.disabled = true;
    } catch (error) {
      console.error('开始共享失败:', error);
      alert(`开始共享失败: ${error.message}`);
    }
  });
  
  // 停止共享
  stopShareBtn.addEventListener('click', async () => {
    try {
      await window.screenShareAPI.stopCapture();
      
      // 清除预览
      if (sharePreview.srcObject) {
        sharePreview.srcObject.getTracks().forEach(track => track.stop());
        sharePreview.srcObject = null;
      }
      
      // 重置状态
      selectedSourceId = null;
      startShareBtn.disabled = true;
      stopShareBtn.disabled = true;
      selectSourceBtn.disabled = false;
      sourceList.innerHTML = '';
    } catch (error) {
      console.error('停止共享失败:', error);
      alert(`停止共享失败: ${error.message}`);
    }
  });
  
  // 窗口关闭时停止共享
  window.addEventListener('beforeunload', () => {
    if (mediaStream) {
      window.screenShareAPI.stopCapture();
    }
  });
  
  // 原有图表初始化代码保持不变
  initChart();
});

// 保持原有的initChart函数不变
const Chart = require('chart.js/auto');
function initChart() {
  // ... (原有代码保持不变)
}

三、WebRTC对等连接实现

3.1 WebRTC连接架构

mermaid

3.2 添加WebRTC连接代码(renderer.js补充)

// 在renderer.js中添加WebRTC相关功能
let peerConnection;
const configuration = {
  iceServers: [
    { urls: 'stun:stun.l.google.com:19302' },
    { urls: 'stun:stun1.l.google.com:19302' }
  ]
};

// 创建P2P连接
async function createPeerConnection(isInitiator) {
  // 创建RTCPeerConnection实例
  peerConnection = new RTCPeerConnection(configuration);
  
  // 添加ICE候选处理
  peerConnection.onicecandidate = (event) => {
    if (event.candidate) {
      // 发送ICE候选到信令服务器
      sendSignalingMessage({
        type: 'candidate',
        candidate: event.candidate
      });
    }
  };
  
  // 处理远程流
  peerConnection.ontrack = (event) => {
    // 这里可以处理接收到的远程流
    console.log('Received remote stream');
    // remoteVideo.srcObject = event.streams[0];
  };
  
  // 如果是发起方,创建offer
  if (isInitiator && mediaStream) {
    // 添加本地流到连接
    mediaStream.getTracks().forEach(track => {
      peerConnection.addTrack(track, mediaStream);
    });
    
    try {
      const offer = await peerConnection.createOffer();
      await peerConnection.setLocalDescription(offer);
      // 发送offer到信令服务器
      sendSignalingMessage({
        type: 'offer',
        sdp: peerConnection.localDescription
      });
    } catch (error) {
      console.error('创建offer失败:', error);
    }
  }
}

// 信令消息处理(实际项目中需要连接到信令服务器)
function handleSignalingMessage(message) {
  if (!peerConnection) {
    // 如果不是发起方,创建连接并设置远程描述
    createPeerConnection(false);
  }
  
  switch (message.type) {
    case 'offer':
      peerConnection.setRemoteDescription(new RTCSessionDescription(message.sdp))
        .then(() => {
          if (mediaStream) {
            mediaStream.getTracks().forEach(track => {
              peerConnection.addTrack(track, mediaStream);
            });
          }
          return peerConnection.createAnswer();
        })
        .then(answer => {
          return peerConnection.setLocalDescription(answer);
        })
        .then(() => {
          sendSignalingMessage({
            type: 'answer',
            sdp: peerConnection.localDescription
          });
        })
        .catch(error => {
          console.error('处理offer失败:', error);
        });
      break;
      
    case 'answer':
      peerConnection.setRemoteDescription(new RTCSessionDescription(message.sdp))
        .catch(error => console.error('处理answer失败:', error));
      break;
      
    case 'candidate':
      if (message.candidate) {
        peerConnection.addIceCandidate(new RTCIceCandidate(message.candidate))
          .catch(error => console.error('添加ICE候选失败:', error));
      }
      break;
  }
}

// 发送信令消息(实际项目中需要实现与信令服务器的通信)
function sendSignalingMessage(message) {
  // 这里简化处理,实际项目中应该通过WebSocket等方式发送到信令服务器
  console.log('发送信令消息:', message);
  
  // 在实际应用中,这里需要连接到信令服务器
  // 例如使用WebSocket:
  // ws.send(JSON.stringify(message));
}

// 在开始共享后添加WebRTC连接初始化
// 修改startShareBtn的click事件处理:
// ... 原有代码 ...
// 开始捕获并获取媒体流
const streamId = await window.screenShareAPI.startCapture(selectedSourceId);

// 获取媒体流
mediaStream = new MediaStream();

// 初始化WebRTC连接(作为发起方)
createPeerConnection(true);
// ... 原有代码 ...

四、跨平台兼容性处理

4.1 平台特定代码处理

不同操作系统对屏幕捕获有不同的权限要求和API行为,需要针对性处理:

// 在main.js中修改getSources处理函数
ipcMain.handle('screen-share:get-sources', async (event, options) => {
  try {
    // 平台特定配置
    const captureOptions = {
      types: ['window', 'screen'],
      thumbnailSize: { 
        width: 1280, 
        height: 720 
      }
    };
    
    // macOS特定配置
    if (process.platform === 'darwin') {
      captureOptions.fetchWindowIcons = true;
    }
    
    // Windows特定配置
    if (process.platform === 'win32') {
      captureOptions.thumbnailSize.width = 1920;
      captureOptions.thumbnailSize.height = 1080;
    }
    
    const sources = await desktopCapturer.getSources(captureOptions);
    
    return sources.map(source => ({
      id: source.id,
      name: source.name,
      thumbnailURL: source.thumbnail.toDataURL(),
      displayId: source.displayId,
      // 平台特定信息
      isScreen: source.name.includes('Screen'),
      isWindow: source.name.includes('Window')
    }));
  } catch (error) {
    console.error('获取捕获源失败:', error);
    throw error;
  }
});

4.2 权限请求处理

macOS和Linux需要额外的权限处理:

// 在main.js中添加权限检查
function checkPermissions() {
  return new Promise((resolve, reject) => {
    // macOS权限检查
    if (process.platform === 'darwin') {
      const { systemPreferences } = require('electron');
      
      // 检查屏幕录制权限
      if (!systemPreferences.getMediaAccessStatus('screen')) {
        systemPreferences.askForMediaAccess('screen')
          .then(granted => {
            if (granted) resolve(true);
            else reject(new Error('需要屏幕录制权限,请在系统偏好设置中启用'));
          });
      } else {
        resolve(true);
      }
    } 
    // Linux不需要额外权限
    else if (process.platform === 'linux') {
      resolve(true);
    } 
    // Windows权限由系统自动处理
    else if (process.platform === 'win32') {
      resolve(true);
    }
  });
}

// 在创建窗口前检查权限
app.whenReady().then(async () => {
  try {
    // 检查权限
    await checkPermissions();
    // 创建窗口
    createWindow();
    // ... 其他初始化代码
  } catch (error) {
    console.error('权限检查失败:', error);
    // 显示权限错误对话框
    dialog.showErrorBox(
      '权限不足',
      error.message + '\n请在系统设置中启用必要的权限后重试。'
    );
    app.quit();
  }
});

4.3 跨平台问题解决方案

问题平台解决方案代码示例
窗口标题乱码Windows使用win32 API获取窗口标题const { nativeImage } = require('electron')
捕获黑屏macOS检查辅助功能权限systemPreferences.getMediaAccessStatus('screen')
性能低下Linux降低捕获分辨率thumbnailSize: { width: 1280, height: 720 }
窗口闪烁Windows禁用硬件加速app.disableHardwareAcceleration()

五、性能优化与最佳实践

5.1 捕获参数优化

// 在main.js中优化捕获参数
ipcMain.handle('screen-share:start-capture', async (event, sourceId) => {
  try {
    // 停止已有捕获
    if (mediaStream) {
      mediaStream.getTracks().forEach(track => track.stop());
    }

    // 动态调整捕获参数
    const captureParams = {
      audio: false,
      video: {
        mandatory: {
          chromeMediaSource: 'desktop',
          chromeMediaSourceId: sourceId,
          // 动态分辨率设置
          minWidth: 1280,
          maxWidth: 1920,
          minHeight: 720,
          maxHeight: 1080,
          // 帧率控制
          minFrameRate: 15,
          maxFrameRate: 30
        }
      }
    };
    
    // 根据源类型调整参数(窗口/屏幕)
    if (sourceId.includes('window')) {
      // 窗口捕获降低分辨率
      captureParams.video.mandatory.maxWidth = 1280;
      captureParams.video.mandatory.maxHeight = 720;
    }
    
    // 低性能设备检测
    const isLowPerformance = process.getSystemMemoryInfo().total < 4 * 1024 * 1024 * 1024; // <4GB内存
    if (isLowPerformance) {
      captureParams.video.mandatory.maxFrameRate = 15;
      captureParams.video.mandatory.maxWidth = 1024;
      captureParams.video.mandatory.maxHeight = 768;
    }

    mediaStream = await navigator.mediaDevices.getUserMedia(captureParams);
    return mediaStream.id;
  } catch (error) {
    console.error('开始捕获失败:', error);
    throw error;
  }
});

5.2 常见问题与解决方案

问题1:捕获帧率低,画面卡顿

解决方案:

  • 降低捕获分辨率
  • 限制最大帧率
  • 禁用不必要的渲染效果
// 在renderer.js中添加帧率控制
function setVideoQuality(qualityLevel) {
  // 0: 低质量, 1: 中等, 2: 高质量
  const qualitySettings = [
    { width: 800, height: 600, frameRate: 15 },
    { width: 1280, height: 720, frameRate: 24 },
    { width: 1920, height: 1080, frameRate: 30 }
  ];
  
  const settings = qualitySettings[qualityLevel];
  
  // 通知主进程更改捕获参数
  window.screenShareAPI.setCaptureParams(settings)
    .then(() => console.log('视频质量已更新'))
    .catch(error => console.error('更新视频质量失败:', error));
}
问题2:远程端延迟高

解决方案:

  • 优化WebRTC配置
  • 使用低延迟编解码器
  • 调整JitterBuffer大小
// 优化WebRTC配置
const configuration = {
  iceServers: [
    { urls: 'stun:stun.l.google.com:19302' },
    // 添加turn服务器改善连接
    { 
      urls: 'turn:your-turn-server.com:3478',
      username: 'username',
      credential: 'credential'
    }
  ],
  // 启用低延迟模式
  iceCandidatePoolSize: 10,
  sdpSemantics: 'unified-plan'
};

// 创建PeerConnection时添加参数
const peerConnection = new RTCPeerConnection({
  ...configuration,
  // 媒体优化参数
  bundlePolicy: 'max-bundle',
  rtcpMuxPolicy: 'require',
  // 低延迟配置
  latencyHint: 'interactive' // 交互模式,低延迟优先
});

// 设置编解码器偏好
peerConnection.addTransceiver('video', { direction: 'sendrecv' });
peerConnection.addTransceiver('audio', { direction: 'sendrecv' });

// 在创建offer时指定编解码器
const offerOptions = {
  offerToReceiveVideo: true,
  offerToReceiveAudio: true,
  voiceActivityDetection: false,
  // 优先使用VP8编解码器
  codecPreferences: ['video/VP8', 'audio/opus']
};
问题3:应用占用CPU过高

解决方案:

  • 实现按需捕获
  • 非活跃时降低帧率
  • 使用硬件编码
// 在renderer.js中实现按需捕获
let isActive = true;

// 监听页面可见性变化
document.addEventListener('visibilitychange', () => {
  isActive = !document.hidden;
  adjustCaptureFrameRate(isActive ? 30 : 5); // 活跃时30fps,非活跃时5fps
});

// 调整捕获帧率
async function adjustCaptureFrameRate(frameRate) {
  if (!mediaStream) return;
  
  const videoTrack = mediaStream.getVideoTracks()[0];
  if (!videoTrack) return;
  
  try {
    // 调整视频轨道约束
    await videoTrack.applyConstraints({
      frameRate: { ideal: frameRate, max: frameRate }
    });
    console.log(`帧率已调整为: ${frameRate}fps`);
  } catch (error) {
    console.error('调整帧率失败:', error);
  }
}

六、项目集成与部署

6.1 项目文件结构

electron-quick-start/
├── index.html          # 添加共享UI
├── main.js             # 添加屏幕捕获主进程代码
├── preload.js          # 添加安全通信层
├── renderer.js         # 添加共享控制逻辑
├── styles.css          # 添加共享相关样式
├── package.json        # 添加依赖
└── assets/
    └── icons/          # 添加托盘和窗口图标

6.2 依赖安装

项目需要添加WebRTC相关依赖:

npm install webrtc-adapter --save
npm install socket.io-client --save # 如果使用Socket.IO作为信令通道

在package.json中添加:

"dependencies": {
  "webrtc-adapter": "^8.2.3",
  "socket.io-client": "^4.5.1",
  // ... 其他依赖
}

6.3 打包配置

修改package.json,添加打包脚本:

"scripts": {
  "start": "electron .",
  "package": "electron-packager . --overwrite --platform=all --icon=assets/icons/icon --out=dist",
  "package:win": "electron-packager . --overwrite --platform=win32 --icon=assets/icons/icon.ico --out=dist",
  "package:mac": "electron-packager . --overwrite --platform=darwin --icon=assets/icons/icon.icns --out=dist",
  "package:linux": "electron-packager . --overwrite --platform=linux --icon=assets/icons/icon.png --out=dist"
}

6.4 安全最佳实践

  1. 权限控制:仅在需要时请求屏幕捕获权限
  2. 验证源ID:确保只处理可信的源ID
  3. 加密传输:使用HTTPS和安全的WebRTC连接
  4. 输入验证:验证所有IPC消息
  5. 最小权限原则:限制渲染进程权限
// 在preload.js中添加消息验证
contextBridge.exposeInMainWorld('screenShareAPI', {
  getSources: () => {
    // 记录请求
    console.log('getSources requested at', new Date().toISOString());
    return ipcRenderer.invoke('screen-share:get-sources');
  },
  startCapture: (sourceId) => {
    // 验证sourceId格式
    if (typeof sourceId !== 'string' || !sourceId.startsWith('window') && !sourceId.startsWith('screen')) {
      console.error('Invalid sourceId:', sourceId);
      throw new Error('Invalid source ID');
    }
    return ipcRenderer.invoke('screen-share:start-capture', sourceId);
  },
  stopCapture: () => ipcRenderer.invoke('screen-share:stop-capture')
});

七、总结与扩展

本文基于gh_mirrors/el/electron-quick-start项目,实现了一套完整的Electron屏幕共享方案,包括:

  1. 基于WebRTC和Electron desktopCapturer的屏幕捕获实现
  2. 安全的进程间通信架构设计
  3. 跨平台兼容性处理
  4. 性能优化与常见问题解决方案
  5. WebRTC实时传输集成

7.1 功能扩展方向

  • 多显示器支持:检测并允许选择特定显示器
  • 音频共享:添加系统音频捕获功能
  • 鼠标指针捕获:显示远程鼠标指针
  • 权限持久化:记住用户的权限选择
  • 录制功能:将共享内容保存为视频文件

7.2 学习资源推荐

如果觉得本文有帮助,请点赞👍收藏🌟关注,下一篇将带来《Electron+WebRTC实现视频会议系统》。

附录:完整代码清单

【免费下载链接】electron-quick-start Clone to try a simple Electron app 【免费下载链接】electron-quick-start 项目地址: https://gitcode.com/gh_mirrors/el/electron-quick-start

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值