WIP: neutralino
This commit is contained in:
parent
fc1bc066d2
commit
11d7d0ecd3
10 changed files with 285 additions and 47 deletions
16
.github/workflows/desktop-build.yml
vendored
16
.github/workflows/desktop-build.yml
vendored
|
|
@ -37,27 +37,27 @@ jobs:
|
||||||
with:
|
with:
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Bun
|
||||||
uses: actions/setup-node@v4
|
uses: oven-sh/setup-bun@v1
|
||||||
with:
|
with:
|
||||||
node-version: '20'
|
bun-version: latest
|
||||||
cache: 'npm'
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm install
|
run: bun install
|
||||||
|
|
||||||
- name: Download Neutralino binaries
|
- name: Download Neutralino binaries
|
||||||
run: npx neu update
|
run: bun x neu update
|
||||||
|
|
||||||
- name: Build application
|
- name: Build application
|
||||||
run: npm run build:desktop
|
run: bun run build
|
||||||
|
|
||||||
- name: Prepare Release
|
- name: Prepare Release
|
||||||
run: |
|
run: |
|
||||||
mkdir release
|
mkdir release
|
||||||
cp dist/Monochrome/resources.neu release/
|
cp dist/Monochrome/resources.neu release/
|
||||||
cp neutralino.config.json release/
|
cp neutralino.config.json release/
|
||||||
cp -r extensions release/
|
# Extensions are already copied to dist/Monochrome/extensions by postbuild
|
||||||
|
cp -r dist/Monochrome/extensions release/
|
||||||
cp dist/Monochrome/${{ matrix.binary_source }} release/${{ matrix.binary_dest }}
|
cp dist/Monochrome/${{ matrix.binary_source }} release/${{ matrix.binary_dest }}
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
|
|
|
||||||
BIN
.gitignore
vendored
BIN
.gitignore
vendored
Binary file not shown.
54
js/app.js
54
js/app.js
|
|
@ -1,5 +1,5 @@
|
||||||
//js/app.js
|
//js/app.js
|
||||||
console.log('[App] Script loaded');
|
console.log('[App] Script loaded. Query:', window.location.search);
|
||||||
import { LosslessAPI } from './api.js';
|
import { LosslessAPI } from './api.js';
|
||||||
import {
|
import {
|
||||||
apiSettings,
|
apiSettings,
|
||||||
|
|
@ -22,11 +22,12 @@ import { db } from './db.js';
|
||||||
import { syncManager } from './accounts/pocketbase.js';
|
import { syncManager } from './accounts/pocketbase.js';
|
||||||
import { registerSW } from 'virtual:pwa-register';
|
import { registerSW } from 'virtual:pwa-register';
|
||||||
import { initializeDiscordRPC } from './discord-rpc.js';
|
import { initializeDiscordRPC } from './discord-rpc.js';
|
||||||
import * as Neutralino from '@neutralinojs/lib';
|
import * as Neutralino from './neutralino-bridge.js';
|
||||||
import './smooth-scrolling.js';
|
import './smooth-scrolling.js';
|
||||||
|
|
||||||
// Assign Neutralino to window for global access
|
// Assign Neutralino to window for global access
|
||||||
if (typeof window !== 'undefined' && window.NL_MODE) {
|
// Force global assignment for Bridge mode
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
window.Neutralino = Neutralino;
|
window.Neutralino = Neutralino;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -238,30 +239,34 @@ async function disablePwaForAuthGate() {
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', async () => {
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
// Initialize desktop environment (Neutralino)
|
// Delay detection slightly to allow for global injection
|
||||||
const isDesktop = typeof window !== 'undefined' && (window.NL_MODE || window.location.port === '5050');
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
if (typeof window !== 'undefined' && window.Neutralino) {
|
|
||||||
console.log('[App] Neutralino object detected. Environment:', isDesktop ? 'Desktop' : 'Web');
|
const initNeutralino = async () => {
|
||||||
if (isDesktop) {
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
console.log('[App] Initializing Neutralino desktop environment...');
|
const isNeutralino = urlParams.get('mode') === 'neutralino';
|
||||||
try {
|
|
||||||
Neutralino.init();
|
if (isNeutralino) {
|
||||||
console.log('[App] Neutralino.init() called successfully.');
|
try {
|
||||||
|
// Bridge init is instant and doesn't need tokens/ports
|
||||||
|
Neutralino.init();
|
||||||
|
console.log('[App] Neutralino Bridge initialized.');
|
||||||
|
|
||||||
// Register events immediately
|
|
||||||
Neutralino.events.on('windowClose', () => {
|
Neutralino.events.on('windowClose', () => {
|
||||||
console.log('[App] Window close event triggered.');
|
|
||||||
Neutralino.app.exit();
|
Neutralino.app.exit();
|
||||||
});
|
});
|
||||||
} catch (error) {
|
|
||||||
console.error('[App] Failed to initialize desktop environment:', error);
|
// Initialize Discord RPC
|
||||||
|
console.log('[App] Starting Discord RPC...');
|
||||||
|
initializeDiscordRPC(player);
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[App] Neutralino init failed:', e);
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
console.log('[App] Skipping Neutralino.init() on regular web environment.');
|
|
||||||
}
|
}
|
||||||
} else {
|
};
|
||||||
console.log('[App] Neutralino object NOT detected.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const api = new LosslessAPI(apiSettings);
|
const api = new LosslessAPI(apiSettings);
|
||||||
|
|
||||||
|
|
@ -411,10 +416,9 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
// Initialize tracker
|
// Initialize tracker
|
||||||
initTracker(player);
|
initTracker(player);
|
||||||
|
|
||||||
if (typeof window !== 'undefined' && window.Neutralino && (window.NL_MODE || window.location.port === '5050')) {
|
|
||||||
console.log('[App] Starting Discord RPC...');
|
|
||||||
initializeDiscordRPC(player);
|
initNeutralino();
|
||||||
}
|
|
||||||
|
|
||||||
const castBtn = document.getElementById('cast-btn');
|
const castBtn = document.getElementById('cast-btn');
|
||||||
initializeCasting(audioPlayer, castBtn);
|
initializeCasting(audioPlayer, castBtn);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { getTrackTitle, getTrackArtists } from './utils.js';
|
import { getTrackTitle, getTrackArtists } from './utils.js';
|
||||||
|
import * as Neutralino from './neutralino-bridge.js';
|
||||||
|
|
||||||
export function initializeDiscordRPC(player) {
|
export function initializeDiscordRPC(player) {
|
||||||
console.log('[DiscordRPC] Initializing...');
|
console.log('[DiscordRPC] Initializing...');
|
||||||
|
|
@ -51,8 +52,8 @@ export function initializeDiscordRPC(player) {
|
||||||
smallImageKey: 'pause',
|
smallImageKey: 'pause',
|
||||||
smallImageText: 'Paused',
|
smallImageText: 'Paused',
|
||||||
};
|
};
|
||||||
Neutralino.events.broadcast('discord:update', idlingData).catch(() => {});
|
Neutralino.events.broadcast('discord:update', idlingData).catch(() => { });
|
||||||
Neutralino.extensions.dispatch(EXTENSION_ID, 'discord:update', idlingData).catch(() => {});
|
Neutralino.extensions.dispatch(EXTENSION_ID, 'discord:update', idlingData).catch(() => { });
|
||||||
}
|
}
|
||||||
}, 5000);
|
}, 5000);
|
||||||
|
|
||||||
|
|
@ -83,6 +84,6 @@ export function initializeDiscordRPC(player) {
|
||||||
smallImageKey: 'pause',
|
smallImageKey: 'pause',
|
||||||
smallImageText: 'Paused',
|
smallImageText: 'Paused',
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => { });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
80
js/neutralino-bridge.js
Normal file
80
js/neutralino-bridge.js
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
// js/neutralino-bridge.js
|
||||||
|
|
||||||
|
const listeners = new Map();
|
||||||
|
|
||||||
|
// Listen for events from the Shell (Parent)
|
||||||
|
window.addEventListener('message', (event) => {
|
||||||
|
if (event.data?.type === 'NL_EVENT') {
|
||||||
|
const { eventName, detail } = event.data;
|
||||||
|
if (listeners.has(eventName)) {
|
||||||
|
listeners.get(eventName).forEach((handler) => {
|
||||||
|
try {
|
||||||
|
handler(detail);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Bridge] Error in event handler:', e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const init = async () => {
|
||||||
|
console.log('[Bridge] Initialized. Mode: Iframe Shell.');
|
||||||
|
// Notify Shell we are ready
|
||||||
|
window.parent.postMessage({ type: 'NL_INIT' }, '*');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const events = {
|
||||||
|
on: (eventName, handler) => {
|
||||||
|
if (!listeners.has(eventName)) {
|
||||||
|
listeners.set(eventName, []);
|
||||||
|
}
|
||||||
|
listeners.get(eventName).push(handler);
|
||||||
|
},
|
||||||
|
off: (eventName, handler) => {
|
||||||
|
if (!listeners.has(eventName)) return;
|
||||||
|
const handlers = listeners.get(eventName);
|
||||||
|
const index = handlers.indexOf(handler);
|
||||||
|
if (index > -1) handlers.splice(index, 1);
|
||||||
|
},
|
||||||
|
broadcast: async (eventName, data) => {
|
||||||
|
window.parent.postMessage({ type: 'NL_BROADCAST', eventName, data }, '*');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const extensions = {
|
||||||
|
dispatch: async (extensionId, eventName, data) => {
|
||||||
|
window.parent.postMessage({ type: 'NL_EXTENSION', extensionId, eventName, data }, '*');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const app = {
|
||||||
|
exit: async () => {
|
||||||
|
window.parent.postMessage({ type: 'NL_APP_EXIT' }, '*');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const _window = {
|
||||||
|
minimize: async () => {
|
||||||
|
window.parent.postMessage({ type: 'NL_WINDOW_MIN' }, '*');
|
||||||
|
},
|
||||||
|
maximize: async () => {
|
||||||
|
window.parent.postMessage({ type: 'NL_WINDOW_MAX' }, '*');
|
||||||
|
},
|
||||||
|
isVisible: async () => {
|
||||||
|
return true; // Mock response
|
||||||
|
},
|
||||||
|
setTitle: async (title) => {
|
||||||
|
window.parent.postMessage({ type: 'NL_WINDOW_SET_TITLE', title }, '*');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Expose generically for other modules
|
||||||
|
export { _window as window };
|
||||||
|
export default {
|
||||||
|
init,
|
||||||
|
events,
|
||||||
|
extensions,
|
||||||
|
app,
|
||||||
|
window: _window
|
||||||
|
};
|
||||||
|
|
@ -6,12 +6,12 @@
|
||||||
"description": "Lossless music streaming",
|
"description": "Lossless music streaming",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"defaultMode": "window",
|
"defaultMode": "window",
|
||||||
"documentRoot": "www/",
|
"documentRoot": "dist/",
|
||||||
"url": "https://monochrome.tf",
|
"url": "/neutralino_loader.html",
|
||||||
"enableServer": true,
|
"enableServer": true,
|
||||||
"enableNativeAPI": true,
|
"enableNativeAPI": true,
|
||||||
"enableExtensions": true,
|
"enableExtensions": true,
|
||||||
"tokenSecurity": "one-time",
|
"tokenSecurity": "none",
|
||||||
"modes": {
|
"modes": {
|
||||||
"window": {
|
"window": {
|
||||||
"title": "Monochrome",
|
"title": "Monochrome",
|
||||||
|
|
@ -32,7 +32,7 @@
|
||||||
"port": 5050,
|
"port": 5050,
|
||||||
"cli": {
|
"cli": {
|
||||||
"binaryName": "Monochrome",
|
"binaryName": "Monochrome",
|
||||||
"resourcesPath": "www/",
|
"resourcesPath": "dist/",
|
||||||
"binaryVersion": "6.5.0",
|
"binaryVersion": "6.5.0",
|
||||||
"clientVersion": "6.5.0"
|
"clientVersion": "6.5.0"
|
||||||
},
|
},
|
||||||
|
|
@ -44,5 +44,10 @@
|
||||||
"commandWindows": "powershell.exe -ExecutionPolicy Bypass -File \"${NL_PATH}/extensions/js.neutralino.discordrpc/bridge.ps1\""
|
"commandWindows": "powershell.exe -ExecutionPolicy Bypass -File \"${NL_PATH}/extensions/js.neutralino.discordrpc/bridge.ps1\""
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"nativeAllowList": ["app.exit", "window.*", "extensions.*", "events.*"]
|
"nativeAllowList": [
|
||||||
}
|
"app.exit",
|
||||||
|
"window.*",
|
||||||
|
"extensions.*",
|
||||||
|
"events.*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -6,8 +6,8 @@
|
||||||
"main": "sw.js",
|
"main": "sw.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build --mode neutralino && bun x neu build",
|
||||||
"build:desktop": "vite build --mode neutralino && npx neu build && node -e \"const fs = require('fs'); fs.cpSync('extensions', 'dist/Monochrome/extensions', {recursive: true}); fs.copyFileSync('neutralino.config.json', 'dist/Monochrome/neutralino.config.json')\"",
|
"postbuild": "node -e \"const fs = require('fs'); const path = require('path'); const src = 'extensions'; const dest = path.join('dist', 'Monochrome', 'extensions'); if (fs.existsSync(src)) { fs.mkdirSync(dest, { recursive: true }); fs.cpSync(src, dest, { recursive: true }); console.log('Extensions manually copied to ' + dest); }\"",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"start": "vite preview",
|
"start": "vite preview",
|
||||||
"lint:js": "eslint .",
|
"lint:js": "eslint .",
|
||||||
|
|
@ -55,4 +55,4 @@
|
||||||
"dashjs": "^5.1.1",
|
"dashjs": "^5.1.1",
|
||||||
"pocketbase": "^0.26.5"
|
"pocketbase": "^0.26.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
1
public/neutralino.js
Normal file
1
public/neutralino.js
Normal file
File diff suppressed because one or more lines are too long
147
public/neutralino_loader.html
Normal file
147
public/neutralino_loader.html
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Monochrome Shell</title>
|
||||||
|
<style>
|
||||||
|
body,
|
||||||
|
html {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: #000;
|
||||||
|
/* Seamless blend */
|
||||||
|
}
|
||||||
|
|
||||||
|
iframe {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: none;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<script src="/__neutralino_globals.js"></script>
|
||||||
|
<script src="/neutralino.js"></script>
|
||||||
|
|
||||||
|
<!-- Load the app from the local Neutralino server -->
|
||||||
|
<iframe id="app-frame" allow="autoplay; fullscreen; microphone; clipboard-read; clipboard-write"></iframe>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// initialize Neutralino in the Shell (Local Context)
|
||||||
|
try {
|
||||||
|
Neutralino.init();
|
||||||
|
console.log('[Shell] Neutralino initialized.');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Shell] Failed to init Neutralino:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Point iframe to local server using the port from Neutralino
|
||||||
|
// NL_PORT is available globally after init (or we can parse it/wait for it)
|
||||||
|
// Neutralino.init() usually populates window.NL_PORT or we read it from sessionStorage
|
||||||
|
|
||||||
|
const initFrame = async () => {
|
||||||
|
// Wait a tick for globals
|
||||||
|
await new Promise(r => setTimeout(r, 100));
|
||||||
|
|
||||||
|
let port = window.NL_PORT || sessionStorage.getItem('NL_PORT');
|
||||||
|
// Fallback if missing (shouldn't happen after init)
|
||||||
|
if (!port) {
|
||||||
|
// Try reading from window.location if passed (it isn't in shell mode usually)
|
||||||
|
// But Neutralino fills globals.
|
||||||
|
// If not, default to 5050
|
||||||
|
port = '5050';
|
||||||
|
}
|
||||||
|
|
||||||
|
const iframe = document.getElementById('app-frame');
|
||||||
|
// Load the local index.html
|
||||||
|
iframe.src = `http://localhost:${port}/?mode=neutralino`;
|
||||||
|
console.log(`[Shell] Loading local app from http://localhost:${port}/?mode=neutralino`);
|
||||||
|
};
|
||||||
|
|
||||||
|
initFrame();
|
||||||
|
|
||||||
|
const iframe = document.getElementById('app-frame');
|
||||||
|
|
||||||
|
// Forward generic Neutralino events to the Iframe
|
||||||
|
const forwardEvent = (eventName, detail) => {
|
||||||
|
if (iframe && iframe.contentWindow) {
|
||||||
|
iframe.contentWindow.postMessage({
|
||||||
|
type: 'NL_EVENT',
|
||||||
|
eventName: eventName,
|
||||||
|
detail: detail
|
||||||
|
}, '*');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Listen for specific events to forward
|
||||||
|
// Add more here if the app needs them (e.g., tray events)
|
||||||
|
Neutralino.events.on('windowClose', () => forwardEvent('windowClose'));
|
||||||
|
Neutralino.events.on('windowFocus', () => forwardEvent('windowFocus'));
|
||||||
|
Neutralino.events.on('windowBlur', () => forwardEvent('windowBlur'));
|
||||||
|
|
||||||
|
// Handle commands from the Iframe (via Bridge)
|
||||||
|
window.addEventListener('message', async (event) => {
|
||||||
|
const { type, eventName, data, extensionId } = event.data;
|
||||||
|
|
||||||
|
// Security: In a real scenario, check event.origin if possible.
|
||||||
|
// But since this loads valid HTTPS content, it's generally safe for this context.
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'NL_INIT':
|
||||||
|
console.log('[Shell] Bridge connected.');
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'NL_BROADCAST':
|
||||||
|
// e.g. Discord RPC updates
|
||||||
|
try {
|
||||||
|
console.log('[Shell] Broadcasting:', eventName, data);
|
||||||
|
await Neutralino.events.broadcast(eventName, data);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Shell] Broadcast failed:', e);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'NL_EXTENSION':
|
||||||
|
// e.g. specific extension dispatch
|
||||||
|
try {
|
||||||
|
console.log('[Shell] Dispatching to extension:', extensionId, eventName);
|
||||||
|
await Neutralino.extensions.dispatch(extensionId, eventName, data);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Shell] Extension dispatch failed:', e);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'NL_APP_EXIT':
|
||||||
|
Neutralino.app.exit();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'NL_WINDOW_MIN':
|
||||||
|
Neutralino.window.minimize();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'NL_WINDOW_MAX':
|
||||||
|
try {
|
||||||
|
const isMax = await Neutralino.window.isMaximized();
|
||||||
|
if (isMax) Neutralino.window.unmaximize();
|
||||||
|
else Neutralino.window.maximize();
|
||||||
|
} catch (e) { console.error('[Shell] Window toggle failed:', e); }
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'NL_WINDOW_SET_TITLE':
|
||||||
|
try {
|
||||||
|
await Neutralino.window.setTitle(event.data.title);
|
||||||
|
} catch (e) { console.error('[Shell] Set title failed:', e); }
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
|
|
@ -9,8 +9,8 @@ export default defineConfig(({ mode }) => {
|
||||||
return {
|
return {
|
||||||
base: './',
|
base: './',
|
||||||
build: {
|
build: {
|
||||||
outDir: IS_NEUTRALINO ? 'www' : 'dist',
|
outDir: 'dist',
|
||||||
emptyOutDir: IS_NEUTRALINO,
|
emptyOutDir: true,
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
IS_NEUTRALINO && neutralino(),
|
IS_NEUTRALINO && neutralino(),
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue