WIP: neutralino

This commit is contained in:
Julien Maille 2026-02-10 13:44:20 +01:00
parent fc1bc066d2
commit 11d7d0ecd3
10 changed files with 285 additions and 47 deletions

View file

@ -37,27 +37,27 @@ jobs:
with:
fetch-depth: 1
- name: Setup Node.js
uses: actions/setup-node@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v1
with:
node-version: '20'
cache: 'npm'
bun-version: latest
- name: Install dependencies
run: npm install
run: bun install
- name: Download Neutralino binaries
run: npx neu update
run: bun x neu update
- name: Build application
run: npm run build:desktop
run: bun run build
- name: Prepare Release
run: |
mkdir release
cp dist/Monochrome/resources.neu 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 }}
shell: bash

BIN
.gitignore vendored

Binary file not shown.

View file

@ -1,5 +1,5 @@
//js/app.js
console.log('[App] Script loaded');
console.log('[App] Script loaded. Query:', window.location.search);
import { LosslessAPI } from './api.js';
import {
apiSettings,
@ -22,11 +22,12 @@ import { db } from './db.js';
import { syncManager } from './accounts/pocketbase.js';
import { registerSW } from 'virtual:pwa-register';
import { initializeDiscordRPC } from './discord-rpc.js';
import * as Neutralino from '@neutralinojs/lib';
import * as Neutralino from './neutralino-bridge.js';
import './smooth-scrolling.js';
// 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;
}
@ -238,30 +239,34 @@ async function disablePwaForAuthGate() {
}
document.addEventListener('DOMContentLoaded', async () => {
// Initialize desktop environment (Neutralino)
const isDesktop = typeof window !== 'undefined' && (window.NL_MODE || window.location.port === '5050');
if (typeof window !== 'undefined' && window.Neutralino) {
console.log('[App] Neutralino object detected. Environment:', isDesktop ? 'Desktop' : 'Web');
if (isDesktop) {
console.log('[App] Initializing Neutralino desktop environment...');
try {
Neutralino.init();
console.log('[App] Neutralino.init() called successfully.');
// Delay detection slightly to allow for global injection
await new Promise((resolve) => setTimeout(resolve, 100));
const initNeutralino = async () => {
const urlParams = new URLSearchParams(window.location.search);
const isNeutralino = urlParams.get('mode') === 'neutralino';
if (isNeutralino) {
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', () => {
console.log('[App] Window close event triggered.');
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);
@ -411,10 +416,9 @@ document.addEventListener('DOMContentLoaded', async () => {
// Initialize tracker
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');
initializeCasting(audioPlayer, castBtn);

View file

@ -1,4 +1,5 @@
import { getTrackTitle, getTrackArtists } from './utils.js';
import * as Neutralino from './neutralino-bridge.js';
export function initializeDiscordRPC(player) {
console.log('[DiscordRPC] Initializing...');
@ -51,8 +52,8 @@ export function initializeDiscordRPC(player) {
smallImageKey: 'pause',
smallImageText: 'Paused',
};
Neutralino.events.broadcast('discord:update', idlingData).catch(() => {});
Neutralino.extensions.dispatch(EXTENSION_ID, 'discord:update', idlingData).catch(() => {});
Neutralino.events.broadcast('discord:update', idlingData).catch(() => { });
Neutralino.extensions.dispatch(EXTENSION_ID, 'discord:update', idlingData).catch(() => { });
}
}, 5000);
@ -83,6 +84,6 @@ export function initializeDiscordRPC(player) {
smallImageKey: 'pause',
smallImageText: 'Paused',
})
.catch(() => {});
.catch(() => { });
}
}

80
js/neutralino-bridge.js Normal file
View 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
};

View file

@ -6,12 +6,12 @@
"description": "Lossless music streaming",
"version": "1.0.0",
"defaultMode": "window",
"documentRoot": "www/",
"url": "https://monochrome.tf",
"documentRoot": "dist/",
"url": "/neutralino_loader.html",
"enableServer": true,
"enableNativeAPI": true,
"enableExtensions": true,
"tokenSecurity": "one-time",
"tokenSecurity": "none",
"modes": {
"window": {
"title": "Monochrome",
@ -32,7 +32,7 @@
"port": 5050,
"cli": {
"binaryName": "Monochrome",
"resourcesPath": "www/",
"resourcesPath": "dist/",
"binaryVersion": "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\""
}
],
"nativeAllowList": ["app.exit", "window.*", "extensions.*", "events.*"]
}
"nativeAllowList": [
"app.exit",
"window.*",
"extensions.*",
"events.*"
]
}

View file

@ -6,8 +6,8 @@
"main": "sw.js",
"scripts": {
"dev": "vite",
"build": "vite 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')\"",
"build": "vite build --mode neutralino && bun x neu build",
"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",
"start": "vite preview",
"lint:js": "eslint .",
@ -55,4 +55,4 @@
"dashjs": "^5.1.1",
"pocketbase": "^0.26.5"
}
}
}

1
public/neutralino.js Normal file

File diff suppressed because one or more lines are too long

View 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>

View file

@ -9,8 +9,8 @@ export default defineConfig(({ mode }) => {
return {
base: './',
build: {
outDir: IS_NEUTRALINO ? 'www' : 'dist',
emptyOutDir: IS_NEUTRALINO,
outDir: 'dist',
emptyOutDir: true,
},
plugins: [
IS_NEUTRALINO && neutralino(),