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:
|
||||
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
BIN
.gitignore
vendored
Binary file not shown.
54
js/app.js
54
js/app.js
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
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",
|
||||
"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.*"
|
||||
]
|
||||
}
|
||||
|
|
@ -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
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 {
|
||||
base: './',
|
||||
build: {
|
||||
outDir: IS_NEUTRALINO ? 'www' : 'dist',
|
||||
emptyOutDir: IS_NEUTRALINO,
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
plugins: [
|
||||
IS_NEUTRALINO && neutralino(),
|
||||
|
|
|
|||
Loading…
Reference in a new issue