Files
dootask/electron/lib/utils.js
T
kuaifan cb414b48f6 refactor: 优化窗口关闭拦截机制,采用声明式注册
- 将 onBeforeUnload 从 utils.js 移至 web-tab-manager.js
- 新增声明式拦截注册机制,前端通过 registerCloseInterceptor 声明需要拦截
- 仅对已声明拦截的页面执行 JS 检查,未声明的直接关闭
- 添加 5 秒超时保护,防止网页卡死导致无法关闭窗口
- 修复 command+w 快捷键关闭整个窗口而非当前 tab 的问题
2026-01-14 22:29:36 +08:00

807 lines
23 KiB
JavaScript
Vendored

const fs = require("fs");
const os = require("os");
const path = require('path')
const dayjs = require("dayjs");
const http = require('http')
const https = require('https')
const crypto = require('crypto')
const {shell, session, net, Notification, nativeTheme} = require("electron");
const loger = require("electron-log");
const Store = require("electron-store");
const store = new Store();
const utils = {
/**
* 时间对象
* @param v
* @returns {*|dayjs.Dayjs}
*/
dayjs(v = undefined) {
if (/^\d{13,}$/.test(v)) {
return dayjs(Number(v));
}
if (/^\d{10,}$/.test(v)) {
return dayjs(Number(v) * 1000);
}
if (v === null) {
v = 0
}
return dayjs(v);
},
/**
* 是否数组
* @param obj
* @returns {boolean}
*/
isArray(obj) {
return typeof (obj) == "object" && Object.prototype.toString.call(obj).toLowerCase() == '[object array]' && typeof obj.length == "number";
},
/**
* 是否数组对象
* @param obj
* @returns {boolean}
*/
isJson(obj) {
return typeof (obj) == "object" && Object.prototype.toString.call(obj).toLowerCase() == "[object object]" && typeof obj.length == "undefined";
},
/**
* 将一个 JSON 字符串转换为对象(已try)
* @param str
* @param defaultVal
* @returns {*}
*/
jsonParse(str, defaultVal = undefined) {
if (str === null) {
return defaultVal ? defaultVal : {};
}
if (typeof str === "object") {
return str;
}
try {
return JSON.parse(str.replace(/\n/g,"\\n").replace(/\r/g,"\\r"));
} catch (e) {
return defaultVal ? defaultVal : {};
}
},
/**
* 将 JavaScript 值转换为 JSON 字符串(已try)
* @param json
* @param defaultVal
* @returns {string}
*/
jsonStringify(json, defaultVal = undefined) {
if (typeof json !== 'object') {
return json;
}
try{
return JSON.stringify(json);
}catch (e) {
return defaultVal ? defaultVal : "";
}
},
/**
* 随机数字
* @param str
* @param fixed
* @returns {number}
*/
runNum(str, fixed = null) {
let _s = Number(str);
if (_s + "" === "NaN") {
_s = 0;
}
if (fixed && /^[0-9]*[1-9][0-9]*$/.test(fixed)) {
_s = _s.toFixed(fixed);
let rs = _s.indexOf('.');
if (rs < 0) {
_s += ".";
for (let i = 0; i < fixed; i++) {
_s += "0";
}
}
}
return _s;
},
/**
* 兜底处理尺寸类数值,返回四舍五入后的正整数
* @param value
* @param fallback
* @returns {number}
*/
normalizeSize(value, fallback) {
const toPositiveNumber = (candidate) => {
const num = Number(candidate);
return Number.isFinite(num) && num > 0 ? num : null;
};
const primary = toPositiveNumber(value);
const secondary = toPositiveNumber(fallback);
const safeValue = primary ?? secondary ?? 1;
return Math.max(1, Math.round(safeValue));
},
/**
* 随机字符串
* @param len
* @returns {string}
*/
randomString(len) {
len = len || 32;
let $chars = 'ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz2345678oOLl9gqVvUuI1';
let maxPos = $chars.length;
let pwd = '';
for (let i = 0; i < len; i++) {
pwd += $chars.charAt(Math.floor(Math.random() * maxPos));
}
return pwd;
},
/**
* 字符串是否包含
* @param string
* @param find
* @param lower
* @returns {boolean}
*/
strExists(string, find, lower = false) {
string += "";
find += "";
if (lower !== true) {
string = string.toLowerCase();
find = find.toLowerCase();
}
return (string.indexOf(find) !== -1);
},
/**
* 字符串是否左边包含
* @param string
* @param find
* @param lower
* @returns {boolean}
*/
leftExists(string, find, lower = false) {
string += "";
find += "";
if (lower !== true) {
string = string.toLowerCase();
find = find.toLowerCase();
}
return (string.substring(0, find.length) === find);
},
/**
* 删除左边字符串
* @param string
* @param find
* @param lower
* @returns {string}
*/
leftDelete(string, find, lower = false) {
string += "";
find += "";
if (utils.leftExists(string, find, lower)) {
string = string.substring(find.length)
}
return string ? string : '';
},
/**
* 字符串是否右边包含
* @param string
* @param find
* @param lower
* @returns {boolean}
*/
rightExists(string, find, lower = false) {
string += "";
find += "";
if (lower !== true) {
string = string.toLowerCase();
find = find.toLowerCase();
}
return (string.substring(string.length - find.length) === find);
},
/**
* 打开文件
* @param filePath
*/
openFile(filePath) {
if (!fs.existsSync(filePath)) {
return
}
shell.openPath(filePath).then(() => {
})
},
/**
* 删除文件夹及文件
* @param filePath
*/
deleteFile(filePath) {
let files = [];
if (fs.existsSync(filePath)) {
files = fs.readdirSync(filePath);
files.forEach(function (file) {
let curPath = filePath + "/" + file;
if (fs.statSync(curPath).isDirectory()) {
utils.deleteFile(curPath);
} else {
fs.unlinkSync(curPath);
}
});
fs.rmdirSync(filePath);
}
},
/**
* 复制文件
* @param srcPath
* @param tarPath
* @param cb
*/
copyFile(srcPath, tarPath, cb) {
let rs = fs.createReadStream(srcPath)
rs.on('error', function (err) {
if (err) {
loger.log('read error', srcPath)
}
cb && cb(err)
})
let ws = fs.createWriteStream(tarPath)
ws.on('error', function (err) {
if (err) {
loger.log('write error', tarPath)
}
cb && cb(err)
})
ws.on('close', function (ex) {
cb && cb(ex)
})
rs.pipe(ws)
},
/**
* 给地址加上前后
* @param str
* @returns {string}
*/
formatUrl(str) {
let url;
if (str.substring(0, 7) === "http://" ||
str.substring(0, 8) === "https://") {
url = str.trim();
} else {
url = "http://" + str.trim();
}
if (url.substring(url.length - 1) != "/") {
url += "/"
}
return url;
},
/**
* 正则提取域名
* @param weburl
* @param toLowerCase
* @returns {string|string}
*/
getDomain(weburl, toLowerCase = true) {
const urlReg = /http(s)?:\/\/([^\/]+)/i;
const domain = `${weburl}`.match(urlReg);
const result = ((domain != null && domain.length > 0) ? domain[2] : "");
return toLowerCase ? result.toLowerCase() : result;
},
/**
* 提取 URL 协议
* @param weburl
* @returns {string}
*/
getProtocol(weburl) {
try {
return new URL(weburl).protocol
} catch(e){
return ""
}
},
/**
* 显示窗口
* @param win
*/
setShowWindow(win) {
if (win) {
if (win.isMinimized()) {
win.restore()
}
win.focus()
win.show()
}
},
/**
* 新窗口打开事件
* @param webContents
* @param url
* @returns {Promise<unknown>}
*/
onBeforeOpenWindow(webContents, url) {
return new Promise(resolve => {
const dataStr = JSON.stringify({url: url})
webContents.executeJavaScript(`if(typeof window.__onBeforeOpenWindow === 'function'){window.__onBeforeOpenWindow(${dataStr})}`, true).then(options => {
if (options !== true) {
resolve()
}
}).catch(_ => {
resolve()
})
})
},
/**
* 分发事件
* @param webContents
* @param data
* @returns {Promise<unknown>}
*/
onDispatchEvent(webContents, data) {
return new Promise(resolve => {
const dataStr = JSON.stringify(data)
webContents.executeJavaScript(`window.__onDispatchEvent(${dataStr})`, true).then(options => {
resolve(options)
}).catch(_ => {
resolve()
})
})
},
/**
* 版本比较
* @param version1
* @param version2
* @returns number 0: 相同,1: version1大,-1: version2大
*/
compareVersion(version1, version2) {
let pA = 0, pB = 0;
// 版本号完全相同
if (version1 === version2) {
return 0
}
// 寻找当前区间的版本号
const findDigit = (str, start) => {
let i = start;
while (str[i] !== '.' && i < str.length) {
i++;
}
return i;
}
while (pA < version1.length && pB < version2.length) {
const nextA = findDigit(version1, pA);
const nextB = findDigit(version2, pB);
const numA = +version1.substr(pA, nextA - pA);
const numB = +version2.substr(pB, nextB - pB);
if (numA !== numB) {
return numA > numB ? 1 : -1;
}
pA = nextA + 1;
pB = nextB + 1;
}
// 若arrayA仍有小版本号
while (pA < version1.length) {
const nextA = findDigit(version1, pA);
const numA = +version1.substr(pA, nextA - pA);
if (numA > 0) {
return 1;
}
pA = nextA + 1;
}
// 若arrayB仍有小版本号
while (pB < version2.length) {
const nextB = findDigit(version2, pB);
const numB = +version2.substr(pB, nextB - pB);
if (numB > 0) {
return -1;
}
pB = nextB + 1;
}
// 版本号完全相同
return 0;
},
/**
* electron15 后,解决跨域cookie无法携带,
*/
useCookie() {
const filter = {urls: ['https://*/*', 'http://*/*']};
session.defaultSession.webRequest.onHeadersReceived(filter, (details, callback) => {
if (details.responseHeaders && details.responseHeaders['Set-Cookie']) {
for (let i = 0; i < details.responseHeaders['Set-Cookie'].length; i++) {
details.responseHeaders['Set-Cookie'][i] += ';SameSite=None;Secure';
}
}
callback({responseHeaders: details.responseHeaders});
});
},
/**
* win mac meta control
* @param input
* @returns {boolean | Point | HTMLElement}
*/
isMetaOrControl(input) {
if (process.platform === 'win32') {
return input.control
} else {
return input.meta
}
},
/**
* MIME类型判断
* @param filePath
* @returns {*|string}
*/
getMimeType(filePath) {
const ext = path.extname(filePath).toLowerCase()
const mimeTypes = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.gif': 'image/gif',
'.svg': 'image/svg+xml',
'.webp': 'image/webp'
}
return mimeTypes[ext] || 'application/octet-stream'
},
/**
* 显示系统通知
* @param {Object} args - 通知参数
* @param {string} args.title - 通知标题
* @param {string} args.body - 通知内容
* @param {string} [args.icon] - 通知图标路径或URL
* @param {Electron.BrowserWindow} [window] - 主窗口实例
* @returns {Promise<void>}
*/
async showNotification(args, window = null) {
try {
// 如果是网络图片,进行缓存处理(仅Windows)
if (process.platform === 'win32' && args.icon && /^https?:\/\//i.test(args.icon)) {
args.icon = await utils.getCachedImage(args.icon);
}
const notifiy = new Notification(args);
notifiy.addListener('click', _ => {
if (window && window.webContents) {
window.webContents.send("clickNotification", args)
if (!window.isVisible()) {
window.show();
}
window.focus();
}
})
notifiy.addListener('reply', (event, reply) => {
if (window && window.webContents) {
window.webContents.send("replyNotification", Object.assign(args, {reply}))
}
})
notifiy.show()
} catch (error) {
loger.error('显示通知失败:', error);
}
},
/**
* 获取缓存的图片路径
* @param {string} imageUrl - 图片URL
* @returns {Promise<string>} 缓存的图片路径
*/
async getCachedImage(imageUrl) {
// 生成图片URL的唯一标识
const urlHash = crypto.createHash('md5').update(imageUrl).digest('hex');
const cacheDir = path.join(os.tmpdir(), 'dootask-cache', 'images');
const cachePath = path.join(cacheDir, `${urlHash}.png`);
try {
// 确保缓存目录存在
if (!fs.existsSync(cacheDir)) {
fs.mkdirSync(cacheDir, { recursive: true });
}
// 检查缓存是否存在
if (!fs.existsSync(cachePath)) {
await utils.downloadImage(imageUrl, cachePath);
}
return cachePath;
} catch (error) {
loger.error('处理缓存图片失败:', error);
return ''; // 返回空字符串,通知将使用默认图标
}
},
/**
* 下载图片
* @param {string} url - 图片URL
* @param {string} filePath - 保存路径
* @returns {Promise<void>}
*/
downloadImage(url, filePath) {
return new Promise((resolve, reject) => {
const file = fs.createWriteStream(filePath);
// 根据协议选择http或https
const protocol = url.startsWith('https') ? https : http;
const request = protocol.get(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
}
}, (response) => {
// 处理重定向
if (response.statusCode === 301 || response.statusCode === 302) {
file.close();
fs.unlink(filePath, () => {});
return utils.downloadImage(response.headers.location, filePath)
.then(resolve)
.catch(reject);
}
// 检查内容类型
const contentType = response.headers['content-type'];
if (!contentType || !contentType.startsWith('image/')) {
file.close();
fs.unlink(filePath, () => {});
reject(new Error(`非图片类型: ${contentType}`));
return;
}
if (response.statusCode !== 200) {
file.close();
fs.unlink(filePath, () => {});
reject(new Error(`下载失败,状态码: ${response.statusCode}`));
return;
}
let downloadedBytes = 0;
response.on('data', (chunk) => {
downloadedBytes += chunk.length;
});
response.pipe(file);
file.on('finish', () => {
// 检查文件大小
if (downloadedBytes === 0) {
file.close();
fs.unlink(filePath, () => {});
reject(new Error('下载的文件大小为0'));
return;
}
file.close();
resolve();
});
});
request.on('error', (err) => {
file.close();
fs.unlink(filePath, () => {});
reject(err);
});
// 设置超时
request.setTimeout(30000, () => {
request.destroy();
file.close();
fs.unlink(filePath, () => {});
reject(new Error('下载超时'));
});
});
},
/**
* 获取并验证 favicon,转换为 base64
* @param {string} faviconUrl - favicon 的 URL
* @param {number} timeout - 超时时间(毫秒),默认 5000
* @returns {Promise<string|null>} - 成功返回 base64 data URL,失败返回 null
*/
async fetchFaviconAsBase64(faviconUrl, timeout = 5000) {
if (!faviconUrl || typeof faviconUrl !== 'string') {
return null;
}
// 如果已经是 base64,直接返回
if (faviconUrl.startsWith('data:')) {
return faviconUrl;
}
return new Promise((resolve) => {
try {
const request = net.request(faviconUrl);
// 设置超时
const timeoutId = setTimeout(() => {
request.abort();
resolve(null);
}, timeout);
const chunks = [];
request.on('response', (response) => {
const contentType = response.headers['content-type'];
// 验证是否为图片类型
const isImage = contentType && (
contentType.includes('image/') ||
contentType.includes('icon')
);
if (response.statusCode !== 200 || !isImage) {
clearTimeout(timeoutId);
resolve(null);
return;
}
response.on('data', (chunk) => {
chunks.push(chunk);
});
response.on('end', () => {
clearTimeout(timeoutId);
try {
const buffer = Buffer.concat(chunks);
// 验证图片数据有效(至少有一些字节)
if (buffer.length < 10) {
resolve(null);
return;
}
// 获取正确的 MIME 类型
let mimeType = 'image/png';
if (contentType) {
const match = contentType.match(/^([^;]+)/);
if (match) {
mimeType = match[1].trim();
}
}
const base64 = buffer.toString('base64');
resolve(`data:${mimeType};base64,${base64}`);
} catch (e) {
resolve(null);
}
});
response.on('error', () => {
clearTimeout(timeoutId);
resolve(null);
});
});
request.on('error', () => {
clearTimeout(timeoutId);
resolve(null);
});
request.end();
} catch (e) {
resolve(null);
}
});
},
/**
* 判断是否是本地URL
* @param url
* @returns {boolean}
*/
isLocalHost(url) {
if (!url) {
return true
}
if (!/^https?:\/\//i.test(url)) {
return true
}
try {
const uri = new URL(url)
return uri.hostname === 'localhost'
} catch (e) {
return false
}
},
/**
* 加载URL或文件
* @param browser
* @param serverUrl
* @param hash
*/
loadUrl(browser, serverUrl, hash = null) {
if (serverUrl) {
if (hash) {
serverUrl = `${serverUrl}#${hash}`
}
browser.loadURL(serverUrl).then(_ => { }).catch(_ => { })
} else {
const options = {}
if (hash) {
options.hash = hash
}
browser.loadFile('./public/index.html', options).then(_ => { }).catch(_ => { })
}
},
/**
* 加载内容 URL(自动判断完整 URL 或相对路径)
* @param webContents - BrowserWindow 或 WebContents 对象
* @param serverUrl - 服务器地址
* @param url - 要加载的 URL(完整 URL 或相对路径)
*/
loadContentUrl(webContents, serverUrl, url) {
if (!url) return;
if (/^https?:/i.test(url)) {
// 完整 URL 直接加载
webContents.loadURL(url).then(_ => { }).catch(_ => { })
} else {
// 相对路径使用 loadUrl 处理
utils.loadUrl(webContents, serverUrl, url)
}
},
/**
* 获取主题名称
* @returns {string|*}
*/
getThemName() {
const themeConf = store.get("themeConf");
if (["dark", "light"].includes(themeConf)) {
return themeConf;
}
return nativeTheme.shouldUseDarkColors ? "dark" : "light";
},
/**
* 获取默认背景颜色
* @returns {string}
*/
getDefaultBackgroundColor() {
if (utils.getThemName() === "dark") {
return "#202124";
} else {
return "#FFFFFF";
}
},
/**
* 清理服务器缓存
*/
clearServerCache() {
try {
// 清理require缓存中的express相关模块
Object.keys(require.cache).forEach(key => {
if (key.includes('express') || key.includes('static')) {
delete require.cache[key];
}
});
console.log('Server cache cleared');
} catch (e) {
console.error('Failed to clear server cache:', e);
}
}
}
module.exports = utils;