dootask/electron/lib/utils.js
kuaifan 4929d44ce7 refactor: 优化标签页加载状态管理与 URL 加载逻辑
- 新增 loadContentUrl 方法统一处理完整 URL 和相对路径的加载
  - 优化标签页加载状态,忽略 SPA 路由切换(isSameDocument),避免频繁闪烁
  - 添加定时检查器确保加载状态正确停止
  - windowClose/windowDestroy 支持识别 tab 页面发送者,仅关闭对应标签
  - 子窗口重启过程中不再意外销毁窗口
  - 微应用打开标签页时传递标题信息
  - isLocalHost 对空 URL 和相对路径返回 true
2026-01-10 15:44:58 +00:00

832 lines
24 KiB
JavaScript
Vendored
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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, dialog, 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
* @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 event
* @param app
*/
onBeforeUnload(event, app) {
return new Promise(resolve => {
const contents = app.webContents
if (contents != null) {
contents.executeJavaScript(`if(typeof window.__onBeforeUnload === 'function'){window.__onBeforeUnload()}`, true).then(options => {
if (utils.isJson(options)) {
let choice = dialog.showMessageBoxSync(app, options)
if (choice === 1) {
contents.executeJavaScript(`if(typeof window.__removeBeforeUnload === 'function'){window.__removeBeforeUnload()}`, true).catch(() => {});
resolve()
}
} else if (options !== true) {
resolve()
}
}).catch(_ => {
resolve()
})
event.preventDefault()
} else {
resolve()
}
})
},
/**
* 新窗口打开事件
* @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 false
}
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 "#0D0D0D";
} 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;