starmusic-pc
1/**
2 * Wails 原生能力桥接(PC 版)
3 *
4 * 设计要点:
5 * - 所有方法在「原生不可用」时安全降级(浏览器里跑 vite dev 也能正常调试);
6 * - Wails 抛出的错误形如 "App.PickScriptFile: xxx",这里统一剥掉前缀,方便直接 toast;
7 * - 事件订阅直接用 window.runtime,避免依赖自动生成的 wailsjs 目录。
8 */
9
10const G = typeof window !== 'undefined' ? window : globalThis
11
12/** 取 Wails 注入的 App 绑定对象 */
13const binding = () => (G.go && G.go.main && G.go.main.App ? G.go.main.App : null)
14
15/** 原生能力是否可用 */
16export const hasNative = () => !!binding()
17
18/**
19 * 调用 Go 侧方法
20 * @param {string} method
21 * @param {...any} args
22 * @returns {Promise<any|undefined>} 原生不可用时返回 undefined
23 */
24export async function invoke(method, ...args) {
25 const app = binding()
26 if (!app || typeof app[method] !== 'function') return undefined
27 try {
28 return await app[method](...args)
29 } catch (err) {
30 const raw = err && err.message ? String(err.message) : String(err)
31 throw new Error(raw.replace(/^App\.[A-Za-z0-9_]+:\s*/, '').trim())
32 }
33}
34
35/** 订阅后端事件(下载进度等),返回取消订阅函数 */
36export function onEvent(name, handler) {
37 const rt = G.runtime
38 if (!rt || typeof rt.EventsOn !== 'function') return () => {}
39 try {
40 const off = rt.EventsOn(name, handler)
41 if (typeof off === 'function') return off
42 } catch (e) {
43 /* 忽略 */
44 }
45 return () => {
46 try {
47 if (rt.EventsOff) rt.EventsOff(name)
48 } catch (e) {
49 /* 忽略 */
50 }
51 }
52}
53
54// ============================================================
55// 应用信息
56// ============================================================
57
58let appInfoCache = null
59
60/** 获取应用/环境信息(带缓存) */
61export async function getAppInfo() {
62 if (appInfoCache) return appInfoCache
63 const info = await invoke('GetAppInfo')
64 if (info) appInfoCache = info
65 return info || null
66}
67
68// ============================================================
69// 选择文件
70// ============================================================
71
72/** 浏览器文件选择回退(原生不可用时使用) */
73export function pickFileByInput(accept, asText = true) {
74 return new Promise((resolve, reject) => {
75 const input = document.createElement('input')
76 input.type = 'file'
77 if (accept) input.accept = accept
78 input.style.display = 'none'
79 input.onchange = () => {
80 const file = input.files && input.files[0]
81 if (input.parentNode) input.parentNode.removeChild(input)
82 if (!file) return reject(new Error('PICK_CANCELED'))
83 const reader = new FileReader()
84 reader.onload = () => resolve({ name: file.name, content: String(reader.result || '') })
85 reader.onerror = () => reject(new Error('读取文件失败'))
86 if (asText) reader.readAsText(file)
87 else reader.readAsDataURL(file)
88 }
89 input.oncancel = () => {
90 if (input.parentNode) input.parentNode.removeChild(input)
91 reject(new Error('PICK_CANCELED'))
92 }
93 document.body.appendChild(input)
94 input.click()
95 })
96}
97
98/**
99 * 原生选择音源脚本文件
100 * @returns {Promise<{name:string, content:string, path?:string}|null>} null 表示原生不可用(应回退)
101 */
102export async function pickTextFileNative(accept) {
103 if (!hasNative()) return null
104 const useScriptDialog = !accept || /\.?js/.test(String(accept))
105 const res = await invoke(useScriptDialog ? 'PickScriptFile' : 'PickTextFile')
106 if (!res) throw new Error('PICK_CANCELED')
107 return { name: res.name, content: res.content, path: res.path }
108}
109
110/** 回退:浏览器选择文本文件 */
111export function pickTextFileByInput(accept) {
112 return pickFileByInput(accept, true)
113}
114
115/**
116 * 原生选择图片
117 * @returns {Promise<{name:string, dataUrl:string, path?:string}|null>} null 表示原生不可用
118 */
119export async function pickImageNative(title) {
120 if (!hasNative()) return null
121 const res = await invoke('PickImageFile', title || '')
122 if (!res) throw new Error('PICK_CANCELED')
123 return { name: res.name, dataUrl: res.dataUrl, path: res.path }
124}
125
126/** 回退:浏览器选择图片 */
127export function pickImageByInput(accept) {
128 return pickFileByInput(accept || 'image/*', false)
129}
130
131/** 原生选择目录 */
132export async function pickDirectory(title) {
133 if (!hasNative()) return ''
134 const dir = await invoke('PickDirectory', title || '选择目录')
135 return dir || ''
136}
137
138// ============================================================
139// 保存文件
140// ============================================================
141
142/** 另存为文本文件,返回保存路径(取消或不可用时返回空串) */
143export async function saveTextFile(defaultName, content) {
144 if (!hasNative()) return ''
145 const path = await invoke('SaveTextFile', defaultName, content)
146 return path || ''
147}
148
149/** 另存为 JSON 文件,返回保存路径(取消或不可用时返回空串) */
150export async function saveJSONFile(defaultName, content) {
151 if (!hasNative()) return ''
152 const path = await invoke('SaveJSONFile', defaultName, content)
153 return path || ''
154}
155
156/** 回退:浏览器直接下载文本 */
157function browserDownload(filename, content, mime = 'text/plain;charset=utf-8') {
158 try {
159 const blob = new Blob([content], { type: mime })
160 const url = URL.createObjectURL(blob)
161 const a = document.createElement('a')
162 a.href = url
163 a.download = filename
164 document.body.appendChild(a)
165 a.click()
166 document.body.removeChild(a)
167 setTimeout(() => URL.revokeObjectURL(url), 4000)
168 return true
169 } catch (e) {
170 return false
171 }
172}
173
174/** 保存文本(自动选择原生 / 浏览器通道) */
175export async function saveText(defaultName, content) {
176 if (hasNative()) return saveTextFile(defaultName, content)
177 browserDownload(defaultName, content)
178 return '(浏览器下载)'
179}
180
181/** 保存 JSON(自动选择原生 / 浏览器通道) */
182export async function saveJSON(defaultName, content) {
183 if (hasNative()) return saveJSONFile(defaultName, content)
184 browserDownload(defaultName, content, 'application/json;charset=utf-8')
185 return '(浏览器下载)'
186}
187
188// ============================================================
189// 下载音频
190// ============================================================
191
192/**
193 * 下载文件到磁盘
194 * @param {string} url
195 * @param {string} filename
196 * @param {string} [dir] 目标目录,留空使用默认下载目录
197 * @returns {Promise<{path:string,name:string,size:number,dir:string}>}
198 */
199export async function downloadToDisk(url, filename, dir) {
200 if (!hasNative()) {
201 // 回退:浏览器下载(无法返回真实路径)
202 const a = document.createElement('a')
203 a.href = url
204 a.download = filename
205 document.body.appendChild(a)
206 a.click()
207 document.body.removeChild(a)
208 return { path: '', name: filename, size: 0, dir: '(浏览器默认下载目录)' }
209 }
210 const res = await invoke('DownloadFile', url, filename, dir || '')
211 if (!res) throw new Error('下载失败')
212 return res
213}
214
215// ============================================================
216// 在线更新
217// ============================================================
218
219/**
220 * 下载更新安装包(Go 侧直连写盘 + MD5 校验)
221 * @param {string} url 安装包地址(服务端下发)
222 * @param {string} [filename] 保存文件名
223 * @param {string} [md5] 服务端给出的 MD5,为空则跳过校验
224 * @returns {Promise<{path:string,name:string,size:number,dir:string,md5:string}>}
225 */
226export async function downloadUpdate(url, filename, md5) {
227 if (!hasNative()) throw new Error('当前环境不支持应用内更新')
228 const res = await invoke('DownloadUpdate', url, filename || '', md5 || '')
229 if (!res || !res.path) throw new Error('下载失败')
230 return res
231}
232
233/**
234 * 启动已下载的安装程序;Go 侧会在此之后退出当前进程,
235 * 由安装包完成文件覆盖(再见时已是新版本)。
236 */
237export async function applyUpdate(installerPath) {
238 if (!hasNative()) throw new Error('当前环境不支持应用内更新')
239 await invoke('ApplyUpdate', installerPath)
240}
241
242/**
243 * 订阅更新包下载进度(Go 事件 update:progress)
244 * @param {(p:{name:string,received:number,total:number,percent:number,done:boolean})=>void} handler
245 * @returns {() => void} 取消订阅
246 */
247export const onUpdateProgress = (handler) => onEvent('update:progress', handler)
248
249// ============================================================
250// 系统集成
251// ============================================================
252
253/** 在文件管理器中定位文件 */
254export async function showInFolder(path) {
255 if (!hasNative() || !path) return false
256 await invoke('ShowInFolder', path)
257 return true
258}
259
260/** 用系统默认程序打开文件/目录 */
261export async function openPath(path) {
262 if (!hasNative() || !path) return false
263 await invoke('OpenPath', path)
264 return true
265}
266
267/**
268 * 用系统默认浏览器打开链接;失败时复制到剪贴板兜底
269 * @returns {Promise<boolean>} 是否成功用浏览器打开
270 */
271export async function openExternal(url) {
272 if (!url) return false
273 if (hasNative()) {
274 try {
275 await invoke('OpenExternalURL', url)
276 return true
277 } catch (e) {
278 /* 落到下面的兜底 */
279 }
280 }
281 try {
282 const win = window.open(url, '_blank', 'noopener,noreferrer')
283 if (win) return true
284 } catch (e) {
285 /* 忽略 */
286 }
287 await setClipboard(url)
288 return false
289}
290
291/** 写入系统剪贴板 */
292export async function setClipboard(text) {
293 const value = String(text == null ? '' : text)
294 if (hasNative()) {
295 const ok = await invoke('SetClipboard', value)
296 if (ok) return true
297 }
298 try {
299 if (navigator.clipboard && window.isSecureContext) {
300 await navigator.clipboard.writeText(value)
301 return true
302 }
303 } catch (e) {
304 /* 继续兜底 */
305 }
306 try {
307 const ta = document.createElement('textarea')
308 ta.value = value
309 ta.style.position = 'fixed'
310 ta.style.opacity = '0'
311 document.body.appendChild(ta)
312 ta.select()
313 const ok = document.execCommand('copy')
314 document.body.removeChild(ta)
315 return ok
316 } catch (e) {
317 return false
318 }
319}
320
321/** 读取系统剪贴板 */
322export async function getClipboard() {
323 if (hasNative()) {
324 const text = await invoke('GetClipboard')
325 if (text) return text
326 }
327 try {
328 if (navigator.clipboard && window.isSecureContext) return await navigator.clipboard.readText()
329 } catch (e) {
330 /* 忽略 */
331 }
332 return ''
333}
334
335// ============================================================
336// 窗口控制
337// ============================================================
338
339export const windowMinimise = () => invoke('WindowMinimise')
340export const windowToggleMaximise = () => invoke('WindowToggleMaximise')
341export const windowClose = () => invoke('WindowClose')
342export const windowIsMaximised = async () => {
343 const v = await invoke('WindowIsMaximised')
344 return !!v
345}
346export const toggleFullscreen = async () => {
347 const v = await invoke('ToggleFullscreen')
348 return !!v
349}
350export const setAlwaysOnTop = (on) => invoke('SetAlwaysOnTop', !!on)
351export const setMiniMode = async (on) => {
352 const v = await invoke('SetMiniMode', !!on)
353 return !!v
354}
355export const isMiniMode = async () => {
356 const v = await invoke('IsMiniMode')
357 return !!v
358}
359export const setWindowTitle = (title) => invoke('SetWindowTitle', title)
360
361/** 默认下载目录 */
362export const getDefaultDownloadDir = async () => (await invoke('GetDefaultDownloadDir')) || ''
363
364/** 应用数据根目录(%AppData%\starmusic-pc) */
365export const getDataDir = async () => (await invoke('GetDataDir')) || ''
366
367/** 打开应用数据目录(日志 + WebView2 数据) */
368export const openConfigFolder = () => invoke('OpenConfigFolder')
369
370/** 打开 localStorage 所在目录(业务配置的物理位置) */
371export const openStorageFolder = () => invoke('OpenStorageFolder')
372
373/** 写客户端日志到本地文件(便于排查) */
374export const writeClientLog = (level, message) => invoke('WriteClientLog', level, String(message))