WIP: neutralino

# Conflicts:
#	js/app.js
#	js/neutralino-bridge.js
#	public/neutralino_loader.html
This commit is contained in:
Julien Maille 2026-02-10 18:40:07 +01:00
parent f90a85aef2
commit 89548fa0d3
6 changed files with 262 additions and 18 deletions

View file

@ -1,8 +1,8 @@
# bridge.ps1 - JSON Depth Fix
# $Log = Join-Path $PSScriptRoot "bridge_final.log"
function Log($m) { }
# bridge.ps1 - Diagnostic Version
$Log = Join-Path $PSScriptRoot "bridge.log"
function Log($m) { Add-Content $Log "$(Get-Date -f 'HH:mm:ss') - $m" }
Log "--- START (DEPTH FIX) ---"
Log "--- START (DIAGNOSTIC) ---"
# 1. PID
$p = Get-Process Monochrome -ErrorAction SilentlyContinue | Select-Object -First 1
@ -37,16 +37,27 @@ $h = New-Object byte[] 8; if ($pipe.Read($h, 0, 8) -eq 8) {
Log "Handshake OK"
}
function Set-Activity($d, $s, $img) {
function Set-Activity($d, $s, $img, $start, $end, $large_text, $small_img, $small_txt) {
$activity = @{
details = [string]$d
state = [string]$s
type = 2
assets = @{
large_image = if ($img -and $img.StartsWith("http")) { [string]$img } else { "monochrome" }
large_text = "Monochrome"
large_text = if ($large_text) { [string]$large_text } else { "Monochrome" }
}
}
if ($small_img) {
$activity.assets.small_image = [string]$small_img
$activity.assets.small_text = [string]$small_txt
}
if ($start -or $end) {
$activity.timestamps = @{}
if ($start) { $activity.timestamps.start = [long]$start }
if ($end) { $activity.timestamps.end = [long]$end }
}
# CRITICAL: -Depth 10 ensures 'assets' is not stringified as a class name
$payload = @{
@ -59,7 +70,7 @@ function Set-Activity($d, $s, $img) {
}
Start-Sleep -Seconds 1
Set-Activity "Idling" "Monochrome" $null
Set-Activity "Idling" "Monochrome" $null $null $null $null $null $null
# 4. Config & WS
$line = [Console]::In.ReadLine()
@ -83,9 +94,9 @@ while ($ws.State -eq "Open") {
$raw = [System.Text.Encoding]::UTF8.GetString($buf, 0, $task.Result.Count)
$msg = $raw | ConvertFrom-Json
if ($msg.event -eq "discord:update") {
Set-Activity $msg.data.details $msg.data.state $msg.data.largeImageKey
Set-Activity $msg.data.details $msg.data.state $msg.data.largeImageKey $msg.data.startTimestamp $msg.data.endTimestamp $msg.data.largeImageText $msg.data.smallImageKey $msg.data.smallImageText
}
elseif ($msg.event -eq "discord:clear") { Set-Activity "Idling" "Monochrome" $null }
elseif ($msg.event -eq "discord:clear") { Set-Activity "Idling" "Monochrome" $null $null $null $null $null $null }
} catch {}
}
}

View file

@ -24,9 +24,9 @@ def recv_packet(s):
return json.loads(payload.decode('utf-8'))
except: return None
def set_activity(ds, pid, details, state, img=None):
def set_activity(ds, pid, details, state, img=None, start=None, end=None, large_text=None, small_img=None, small_txt=None):
global LAST_STATUS
current = f"{details}-{state}-{img}"
current = f"{details}-{state}-{img}-{start}-{end}-{large_text}-{small_img}-{small_txt}"
if current == LAST_STATUS: return
LAST_STATUS = current
@ -36,9 +36,18 @@ def set_activity(ds, pid, details, state, img=None):
"type": 2, # Listening
"assets": {
"large_image": img if img and img.startswith('http') else "monochrome",
"large_text": "Monochrome"
"large_text": str(large_text or "Monochrome")
}
}
if small_img:
activity["assets"]["small_image"] = str(small_img)
activity["assets"]["small_text"] = str(small_txt or "")
if start or end:
activity["timestamps"] = {}
if start: activity["timestamps"]["start"] = int(start)
if end: activity["timestamps"]["end"] = int(end)
send_packet(ds, 1, {
"cmd": "SET_ACTIVITY",
@ -119,7 +128,7 @@ def main():
msg = json.loads(data.decode('utf-8'))
if msg['event'] == 'discord:update':
d = msg['data']
set_activity(ds, ppid, d.get('details'), d.get('state'), d.get('largeImageKey'))
set_activity(ds, ppid, d.get('details'), d.get('state'), d.get('largeImageKey'), d.get('startTimestamp'), d.get('endTimestamp'), d.get('largeImageText'), d.get('smallImageKey'), d.get('smallImageText'))
elif msg['event'] == 'discord:clear':
set_activity(ds, ppid, "Idling", "Monochrome")
elif msg['event'] == 'windowClose':

View file

@ -1,5 +1,4 @@
//js/app.js
console.log('[App] Script loaded');
import { LosslessAPI } from './api.js';
import {
apiSettings,

View file

@ -1,8 +1,6 @@
import { getTrackTitle, getTrackArtists } from './utils.js';
export function initializeDiscordRPC(player) {
console.log('[DiscordRPC] Initializing...');
const EXTENSION_ID = 'js.neutralino.discordrpc';
function sendUpdate(track, isPaused = false) {
@ -27,10 +25,12 @@ export function initializeDiscordRPC(player) {
if (!isPaused && track.duration) {
const now = Date.now();
const elapsed = player.audio.currentTime * 1000;
const remaining = (track.duration - player.audio.currentTime) * 1000;
data.startTimestamp = Math.floor((now - elapsed) / 1000);
data.endTimestamp = Math.floor((now + remaining) / 1000);
}
console.log('[DiscordRPC] Dispatching to', EXTENSION_ID, data);
Neutralino.events.broadcast('discord:update', data).catch((e) => console.error('Broadcast failed', e));
Neutralino.extensions
.dispatch(EXTENSION_ID, 'discord:update', data)
@ -42,7 +42,6 @@ export function initializeDiscordRPC(player) {
if (player.currentTrack) {
sendUpdate(player.currentTrack, player.audio.paused);
} else {
console.log('[DiscordRPC] Sending idling heartbeat...');
const idlingData = {
details: 'Idling',
state: 'Monochrome',

79
js/neutralino-bridge.js Normal file
View file

@ -0,0 +1,79 @@
// 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 () => {
// 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

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