feat: F4.6g4 the Gnutella window, measured against Java's
GnutellaWindowRun uses Java's Gnutella window on terminal-net beside the line of GnutellaRun's story "search". The core cancels a search as Java does (every connection of the program's own requests closed, passed-on queries kept), lets the number of peers change and lists /peer2peer's files; the room takes the window's actions and sends its view; the editor shows Java's four tabs with Java's symbols. Not adopted from Java: a number of peers of one digit only, a results table that keeps the last search's rows, a files table that lags behind the folder. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -155,6 +155,28 @@ export function gnutellaPacket(text: string): GnutellaPacket | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* A search result as the window's table shows it (updateErgebnisTabelle):
|
||||
* the address before its first "/", the name up to the last ":" of what
|
||||
* follows, the size after it (" 8 B", with Java's space).
|
||||
*/
|
||||
export function gnutellaResultRow(result: string): readonly [string, string, string] {
|
||||
const [address = '', rest = ''] = result.split('/').filter((part) => part !== '');
|
||||
const colon = rest.lastIndexOf(':');
|
||||
return [address, colon < 0 ? rest : rest.slice(0, colon), colon < 0 ? '' : rest.slice(colon + 1)];
|
||||
}
|
||||
|
||||
/**
|
||||
* The number of peers typed into the window's settings, as it takes it: a
|
||||
* whole number of Java's int; undefined for anything else. Java takes a
|
||||
* single digit only (its pattern musterNurZahl is "\d"): 12 is refused.
|
||||
*/
|
||||
export function gnutellaPeerCountOfText(text: string): number | undefined {
|
||||
if (!/^[0-9]+$/.test(text)) return undefined;
|
||||
const count = Number(text);
|
||||
return count <= 2_147_483_647 ? count : undefined;
|
||||
}
|
||||
|
||||
/** What the program tells its window: its known peers, or its search results ("address/name: size B"), whenever they change. */
|
||||
export type GnutellaLogEntry = { readonly kind: 'peers'; readonly peers: readonly string[] } | { readonly kind: 'results'; readonly results: readonly string[] };
|
||||
|
||||
@@ -221,7 +243,9 @@ export class GnutellaPeer {
|
||||
readonly log: ProgramLog<GnutellaLogEntry>;
|
||||
private peersValue: string[] = [];
|
||||
private resultsValue: string[] = [];
|
||||
private readonly maximumKnownPeers: number;
|
||||
private maximumKnownPeersValue: number;
|
||||
/** The connections of the program's own pings, queries and downloads (mitarbeiterEigeneAnfragen) while they are open. */
|
||||
private readonly ownSockets = new Set<Id>();
|
||||
/**
|
||||
* The ids of the program's own pings and queries (eigeneAnfragen), and of
|
||||
* those it sent on (schonmalVerschicktListe). Java also keeps others'
|
||||
@@ -238,7 +262,7 @@ export class GnutellaPeer {
|
||||
configuration: GnutellaConfiguration,
|
||||
) {
|
||||
this.log = new ProgramLog(engine);
|
||||
this.maximumKnownPeers = configuration.maximumKnownPeers;
|
||||
this.maximumKnownPeersValue = configuration.maximumKnownPeers;
|
||||
this.server = new GnutellaServer(engine, nodeId, this);
|
||||
}
|
||||
|
||||
@@ -252,6 +276,26 @@ export class GnutellaPeer {
|
||||
return this.resultsValue;
|
||||
}
|
||||
|
||||
/** How many peers the program keeps at most (getMaxTeilnehmerZahl). */
|
||||
get maximumKnownPeers(): number {
|
||||
return this.maximumKnownPeersValue;
|
||||
}
|
||||
|
||||
/** setMaxTeilnehmerZahl: for the peers to come; those known stay. */
|
||||
setMaximumKnownPeers(count: number): void {
|
||||
this.maximumKnownPeersValue = count;
|
||||
}
|
||||
|
||||
/**
|
||||
* abbrechenSuche: every connection of the program's own requests closed —
|
||||
* its searches' (hits still on their way are lost), its joins', its
|
||||
* downloads' — as Java ends each worker and closes its socket.
|
||||
*/
|
||||
cancelSearch(): void {
|
||||
// Each leaves the set when its thread ends.
|
||||
for (const socketId of this.ownSockets) this.engine.requestTcpClose(socketId);
|
||||
}
|
||||
|
||||
/** The node's first address (Betriebssystem.primaryIPAdresse). */
|
||||
private get ownAddress(): string {
|
||||
return this.engine.networkInterfaces(this.nodeId)[0]?.ipAddress ?? '';
|
||||
@@ -313,7 +357,7 @@ export class GnutellaPeer {
|
||||
|
||||
/** hinzuTeilnehmer: a new peer while there is room. */
|
||||
private addPeer(address: string): void {
|
||||
if (this.peersValue.includes(address) || this.peersValue.length >= this.maximumKnownPeers) return;
|
||||
if (this.peersValue.includes(address) || this.peersValue.length >= this.maximumKnownPeersValue) return;
|
||||
this.peersValue = [...this.peersValue, address];
|
||||
this.log.write({ kind: 'peers', peers: this.peersValue });
|
||||
}
|
||||
@@ -420,8 +464,10 @@ export class GnutellaPeer {
|
||||
private *fetchFile(owner: string, name: string): Steps {
|
||||
const engine = this.engine;
|
||||
const socketId = engine.openTcpClientSocket(this.nodeId, owner, GNUTELLA_PORT);
|
||||
if (socketId === undefined || (yield* engine.connectTcpSteps(socketId)) !== 'connected') {
|
||||
if (socketId !== undefined) engine.requestTcpClose(socketId);
|
||||
if (socketId === undefined) return;
|
||||
this.ownSockets.add(socketId);
|
||||
if ((yield* engine.connectTcpSteps(socketId)) !== 'connected') {
|
||||
this.closeOwn(socketId);
|
||||
return;
|
||||
}
|
||||
yield* engine.sendTcpSteps(socketId, new TextEncoder().encode(formatHttpRequest({ method: HTTP_GET, path: name, host: owner })));
|
||||
@@ -431,7 +477,13 @@ export class GnutellaPeer {
|
||||
const path = `${GNUTELLA_FOLDER}/${name}`;
|
||||
this.keepFile(path, fileContentOfJavaType(response.contentType ?? '', response.body ?? '', path));
|
||||
}
|
||||
engine.requestTcpClose(socketId);
|
||||
this.closeOwn(socketId);
|
||||
}
|
||||
|
||||
/** A connection closed, and no longer one of the program's own (it may be so already). */
|
||||
private closeOwn(socketId: Id): void {
|
||||
this.engine.requestTcpClose(socketId);
|
||||
this.ownSockets.delete(socketId);
|
||||
}
|
||||
|
||||
/** speicherDatei: over a file of that name in any case, keeping its name (as the file system does). */
|
||||
@@ -461,6 +513,17 @@ export class GnutellaPeer {
|
||||
});
|
||||
}
|
||||
|
||||
/** The files of /peer2peer as the window's table shows them (updateDateiTabelle): name and type. */
|
||||
fileRows(): readonly (readonly [string, string])[] {
|
||||
const files = this.engine.state.topologySnapshot.fileSystemsByNodeId.get(this.nodeId);
|
||||
if (files === undefined || !files.contains(GNUTELLA_FOLDER)) return [];
|
||||
return files.entriesInCreationOrder(GNUTELLA_FOLDER).flatMap((entry) => {
|
||||
const content = entry.content;
|
||||
const name = entry.path.slice(entry.path.lastIndexOf('/') + 1);
|
||||
return content.kind === 'directory' ? [] : [[name, javaFileTypeName(name, content)] as const];
|
||||
});
|
||||
}
|
||||
|
||||
/** A file at the path, found in any case (Dateisystem.holeDatei); none for a folder or nothing. */
|
||||
private sharedFile(path: string): { readonly path: string; readonly name: string; readonly content: Exclude<VirtualFileContent, { kind: 'directory' }> } | undefined {
|
||||
const files = this.engine.state.topologySnapshot.fileSystemsByNodeId.get(this.nodeId);
|
||||
@@ -480,8 +543,10 @@ export class GnutellaPeer {
|
||||
private *sendTo(address: string, packet: GnutellaPacket): Steps {
|
||||
const engine = this.engine;
|
||||
const socketId = engine.openTcpClientSocket(this.nodeId, address, GNUTELLA_PORT);
|
||||
if (socketId === undefined || (yield* engine.connectTcpSteps(socketId)) !== 'connected') {
|
||||
if (socketId !== undefined) engine.requestTcpClose(socketId);
|
||||
if (socketId === undefined) return;
|
||||
if (this.ownRequests.has(packet.guid)) this.ownSockets.add(socketId);
|
||||
if ((yield* engine.connectTcpSteps(socketId)) !== 'connected') {
|
||||
this.closeOwn(socketId);
|
||||
return;
|
||||
}
|
||||
yield* engine.sendTcpSteps(socketId, new TextEncoder().encode(gnutellaText(packet)));
|
||||
@@ -492,6 +557,6 @@ export class GnutellaPeer {
|
||||
if (answer?.kind === 'pong') this.takePong(answer);
|
||||
else if (answer?.kind === 'queryHit') this.takeQueryHit(answer);
|
||||
}
|
||||
engine.requestTcpClose(socketId);
|
||||
this.closeOwn(socketId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,838 @@
|
||||
{
|
||||
"steps": [
|
||||
{
|
||||
"step": "open",
|
||||
"address": {
|
||||
"text": "192.168.0.1",
|
||||
"red": false
|
||||
},
|
||||
"peers": [],
|
||||
"search": "",
|
||||
"results": [],
|
||||
"files": [],
|
||||
"maximum": {
|
||||
"text": "1",
|
||||
"red": false
|
||||
},
|
||||
"programMaximum": 1,
|
||||
"programPeers": [],
|
||||
"programResults": [],
|
||||
"programFiles": [],
|
||||
"dialogs": []
|
||||
},
|
||||
{
|
||||
"step": "type an address that is none",
|
||||
"address": {
|
||||
"text": "192.168.0.300",
|
||||
"red": true
|
||||
},
|
||||
"peers": [],
|
||||
"search": "",
|
||||
"results": [],
|
||||
"files": [],
|
||||
"maximum": {
|
||||
"text": "1",
|
||||
"red": false
|
||||
},
|
||||
"programMaximum": 1,
|
||||
"programPeers": [],
|
||||
"programResults": [],
|
||||
"programFiles": [],
|
||||
"dialogs": []
|
||||
},
|
||||
{
|
||||
"step": "join with an address that is none",
|
||||
"address": {
|
||||
"text": "192.168.0.300",
|
||||
"red": true
|
||||
},
|
||||
"peers": [],
|
||||
"search": "",
|
||||
"results": [],
|
||||
"files": [],
|
||||
"maximum": {
|
||||
"text": "1",
|
||||
"red": false
|
||||
},
|
||||
"programMaximum": 1,
|
||||
"programPeers": [],
|
||||
"programResults": [],
|
||||
"programFiles": [],
|
||||
"dialogs": [
|
||||
"Dies ist keine gültige IP-Adresse"
|
||||
]
|
||||
},
|
||||
{
|
||||
"step": "join itself",
|
||||
"address": {
|
||||
"text": "192.168.0.20",
|
||||
"red": false
|
||||
},
|
||||
"peers": [],
|
||||
"search": "",
|
||||
"results": [],
|
||||
"files": [],
|
||||
"maximum": {
|
||||
"text": "1",
|
||||
"red": false
|
||||
},
|
||||
"programMaximum": 1,
|
||||
"programPeers": [],
|
||||
"programResults": [],
|
||||
"programFiles": [],
|
||||
"dialogs": []
|
||||
},
|
||||
{
|
||||
"step": "join 192.168.0.30",
|
||||
"address": {
|
||||
"text": "192.168.0.30",
|
||||
"red": false
|
||||
},
|
||||
"peers": [
|
||||
[
|
||||
"1",
|
||||
"192.168.0.30"
|
||||
]
|
||||
],
|
||||
"search": "",
|
||||
"results": [],
|
||||
"files": [],
|
||||
"maximum": {
|
||||
"text": "1",
|
||||
"red": false
|
||||
},
|
||||
"programMaximum": 1,
|
||||
"programPeers": [
|
||||
"192.168.0.30"
|
||||
],
|
||||
"programResults": [],
|
||||
"programFiles": [],
|
||||
"dialogs": []
|
||||
},
|
||||
{
|
||||
"step": "search woche",
|
||||
"address": {
|
||||
"text": "192.168.0.30",
|
||||
"red": false
|
||||
},
|
||||
"peers": [
|
||||
[
|
||||
"1",
|
||||
"192.168.0.30"
|
||||
]
|
||||
],
|
||||
"search": "woche",
|
||||
"results": [
|
||||
[
|
||||
"10.0.0.2",
|
||||
"Lied der Woche.txt",
|
||||
" 3 B"
|
||||
]
|
||||
],
|
||||
"files": [],
|
||||
"maximum": {
|
||||
"text": "1",
|
||||
"red": false
|
||||
},
|
||||
"programMaximum": 1,
|
||||
"programPeers": [
|
||||
"192.168.0.30"
|
||||
],
|
||||
"programResults": [
|
||||
"10.0.0.2/Lied der Woche.txt: 3 B"
|
||||
],
|
||||
"programFiles": [],
|
||||
"dialogs": []
|
||||
},
|
||||
{
|
||||
"step": "download row 0",
|
||||
"address": {
|
||||
"text": "192.168.0.30",
|
||||
"red": false
|
||||
},
|
||||
"peers": [
|
||||
[
|
||||
"1",
|
||||
"192.168.0.30"
|
||||
]
|
||||
],
|
||||
"search": "woche",
|
||||
"results": [
|
||||
[
|
||||
"10.0.0.2",
|
||||
"Lied der Woche.txt",
|
||||
" 3 B"
|
||||
]
|
||||
],
|
||||
"files": [],
|
||||
"maximum": {
|
||||
"text": "1",
|
||||
"red": false
|
||||
},
|
||||
"programMaximum": 1,
|
||||
"programPeers": [
|
||||
"192.168.0.30"
|
||||
],
|
||||
"programResults": [
|
||||
"10.0.0.2/Lied der Woche.txt: 3 B"
|
||||
],
|
||||
"programFiles": [
|
||||
[
|
||||
"Lied der Woche.txt",
|
||||
"txt",
|
||||
"La la la"
|
||||
]
|
||||
],
|
||||
"dialogs": []
|
||||
},
|
||||
{
|
||||
"step": "download row 0 again",
|
||||
"address": {
|
||||
"text": "192.168.0.30",
|
||||
"red": false
|
||||
},
|
||||
"peers": [
|
||||
[
|
||||
"1",
|
||||
"192.168.0.30"
|
||||
]
|
||||
],
|
||||
"search": "woche",
|
||||
"results": [
|
||||
[
|
||||
"10.0.0.2",
|
||||
"Lied der Woche.txt",
|
||||
" 3 B"
|
||||
]
|
||||
],
|
||||
"files": [],
|
||||
"maximum": {
|
||||
"text": "1",
|
||||
"red": false
|
||||
},
|
||||
"programMaximum": 1,
|
||||
"programPeers": [
|
||||
"192.168.0.30"
|
||||
],
|
||||
"programResults": [
|
||||
"10.0.0.2/Lied der Woche.txt: 3 B"
|
||||
],
|
||||
"programFiles": [
|
||||
[
|
||||
"Lied der Woche.txt",
|
||||
"txt",
|
||||
"La la la"
|
||||
]
|
||||
],
|
||||
"dialogs": []
|
||||
},
|
||||
{
|
||||
"step": "search xyz",
|
||||
"address": {
|
||||
"text": "192.168.0.30",
|
||||
"red": false
|
||||
},
|
||||
"peers": [
|
||||
[
|
||||
"1",
|
||||
"192.168.0.30"
|
||||
]
|
||||
],
|
||||
"search": "xyz",
|
||||
"results": [
|
||||
[
|
||||
"10.0.0.2",
|
||||
"Lied der Woche.txt",
|
||||
" 3 B"
|
||||
]
|
||||
],
|
||||
"files": [],
|
||||
"maximum": {
|
||||
"text": "1",
|
||||
"red": false
|
||||
},
|
||||
"programMaximum": 1,
|
||||
"programPeers": [
|
||||
"192.168.0.30"
|
||||
],
|
||||
"programResults": [],
|
||||
"programFiles": [
|
||||
[
|
||||
"Lied der Woche.txt",
|
||||
"txt",
|
||||
"La la la"
|
||||
]
|
||||
],
|
||||
"dialogs": []
|
||||
},
|
||||
{
|
||||
"step": "empty the list",
|
||||
"address": {
|
||||
"text": "192.168.0.30",
|
||||
"red": false
|
||||
},
|
||||
"peers": [
|
||||
[
|
||||
"1",
|
||||
"192.168.0.30"
|
||||
]
|
||||
],
|
||||
"search": "xyz",
|
||||
"results": [],
|
||||
"files": [],
|
||||
"maximum": {
|
||||
"text": "1",
|
||||
"red": false
|
||||
},
|
||||
"programMaximum": 1,
|
||||
"programPeers": [
|
||||
"192.168.0.30"
|
||||
],
|
||||
"programResults": [],
|
||||
"programFiles": [
|
||||
[
|
||||
"Lied der Woche.txt",
|
||||
"txt",
|
||||
"La la la"
|
||||
]
|
||||
],
|
||||
"dialogs": []
|
||||
},
|
||||
{
|
||||
"step": "search notiz and abort at once",
|
||||
"address": {
|
||||
"text": "192.168.0.30",
|
||||
"red": false
|
||||
},
|
||||
"peers": [
|
||||
[
|
||||
"1",
|
||||
"192.168.0.30"
|
||||
]
|
||||
],
|
||||
"search": "notiz",
|
||||
"results": [],
|
||||
"files": [],
|
||||
"maximum": {
|
||||
"text": "1",
|
||||
"red": false
|
||||
},
|
||||
"programMaximum": 1,
|
||||
"programPeers": [
|
||||
"192.168.0.30"
|
||||
],
|
||||
"programResults": [],
|
||||
"programFiles": [
|
||||
[
|
||||
"Lied der Woche.txt",
|
||||
"txt",
|
||||
"La la la"
|
||||
]
|
||||
],
|
||||
"dialogs": []
|
||||
},
|
||||
{
|
||||
"step": "search notiz again",
|
||||
"address": {
|
||||
"text": "192.168.0.30",
|
||||
"red": false
|
||||
},
|
||||
"peers": [
|
||||
[
|
||||
"1",
|
||||
"192.168.0.30"
|
||||
]
|
||||
],
|
||||
"search": "notiz",
|
||||
"results": [
|
||||
[
|
||||
"192.168.0.31",
|
||||
"notizen.txt",
|
||||
" 3 B"
|
||||
]
|
||||
],
|
||||
"files": [
|
||||
[
|
||||
"Lied der Woche.txt",
|
||||
"txt"
|
||||
]
|
||||
],
|
||||
"maximum": {
|
||||
"text": "1",
|
||||
"red": false
|
||||
},
|
||||
"programMaximum": 1,
|
||||
"programPeers": [
|
||||
"192.168.0.30"
|
||||
],
|
||||
"programResults": [
|
||||
"192.168.0.31/notizen.txt: 3 B"
|
||||
],
|
||||
"programFiles": [
|
||||
[
|
||||
"Lied der Woche.txt",
|
||||
"txt",
|
||||
"La la la"
|
||||
]
|
||||
],
|
||||
"dialogs": []
|
||||
},
|
||||
{
|
||||
"step": "download with no row chosen",
|
||||
"address": {
|
||||
"text": "192.168.0.30",
|
||||
"red": false
|
||||
},
|
||||
"peers": [
|
||||
[
|
||||
"1",
|
||||
"192.168.0.30"
|
||||
]
|
||||
],
|
||||
"search": "notiz",
|
||||
"results": [
|
||||
[
|
||||
"192.168.0.31",
|
||||
"notizen.txt",
|
||||
" 3 B"
|
||||
]
|
||||
],
|
||||
"files": [
|
||||
[
|
||||
"Lied der Woche.txt",
|
||||
"txt"
|
||||
]
|
||||
],
|
||||
"maximum": {
|
||||
"text": "1",
|
||||
"red": false
|
||||
},
|
||||
"programMaximum": 1,
|
||||
"programPeers": [
|
||||
"192.168.0.30"
|
||||
],
|
||||
"programResults": [
|
||||
"192.168.0.31/notizen.txt: 3 B"
|
||||
],
|
||||
"programFiles": [
|
||||
[
|
||||
"Lied der Woche.txt",
|
||||
"txt",
|
||||
"La la la"
|
||||
]
|
||||
],
|
||||
"dialogs": []
|
||||
},
|
||||
{
|
||||
"step": "files tab",
|
||||
"address": {
|
||||
"text": "192.168.0.30",
|
||||
"red": false
|
||||
},
|
||||
"peers": [
|
||||
[
|
||||
"1",
|
||||
"192.168.0.30"
|
||||
]
|
||||
],
|
||||
"search": "notiz",
|
||||
"results": [
|
||||
[
|
||||
"192.168.0.31",
|
||||
"notizen.txt",
|
||||
" 3 B"
|
||||
]
|
||||
],
|
||||
"files": [
|
||||
[
|
||||
"Lied der Woche.txt",
|
||||
"txt"
|
||||
]
|
||||
],
|
||||
"maximum": {
|
||||
"text": "1",
|
||||
"red": false
|
||||
},
|
||||
"programMaximum": 1,
|
||||
"programPeers": [
|
||||
"192.168.0.30"
|
||||
],
|
||||
"programResults": [
|
||||
"192.168.0.31/notizen.txt: 3 B"
|
||||
],
|
||||
"programFiles": [
|
||||
[
|
||||
"Lied der Woche.txt",
|
||||
"txt",
|
||||
"La la la"
|
||||
]
|
||||
],
|
||||
"dialogs": []
|
||||
},
|
||||
{
|
||||
"step": "type a number of peers that is none",
|
||||
"address": {
|
||||
"text": "192.168.0.30",
|
||||
"red": false
|
||||
},
|
||||
"peers": [
|
||||
[
|
||||
"1",
|
||||
"192.168.0.30"
|
||||
]
|
||||
],
|
||||
"search": "notiz",
|
||||
"results": [
|
||||
[
|
||||
"192.168.0.31",
|
||||
"notizen.txt",
|
||||
" 3 B"
|
||||
]
|
||||
],
|
||||
"files": [
|
||||
[
|
||||
"Lied der Woche.txt",
|
||||
"txt"
|
||||
]
|
||||
],
|
||||
"maximum": {
|
||||
"text": "abc",
|
||||
"red": true
|
||||
},
|
||||
"programMaximum": 1,
|
||||
"programPeers": [
|
||||
"192.168.0.30"
|
||||
],
|
||||
"programResults": [
|
||||
"192.168.0.31/notizen.txt: 3 B"
|
||||
],
|
||||
"programFiles": [
|
||||
[
|
||||
"Lied der Woche.txt",
|
||||
"txt",
|
||||
"La la la"
|
||||
]
|
||||
],
|
||||
"dialogs": []
|
||||
},
|
||||
{
|
||||
"step": "take a number of peers that is none",
|
||||
"address": {
|
||||
"text": "192.168.0.30",
|
||||
"red": false
|
||||
},
|
||||
"peers": [
|
||||
[
|
||||
"1",
|
||||
"192.168.0.30"
|
||||
]
|
||||
],
|
||||
"search": "notiz",
|
||||
"results": [
|
||||
[
|
||||
"192.168.0.31",
|
||||
"notizen.txt",
|
||||
" 3 B"
|
||||
]
|
||||
],
|
||||
"files": [
|
||||
[
|
||||
"Lied der Woche.txt",
|
||||
"txt"
|
||||
]
|
||||
],
|
||||
"maximum": {
|
||||
"text": "abc",
|
||||
"red": true
|
||||
},
|
||||
"programMaximum": 1,
|
||||
"programPeers": [
|
||||
"192.168.0.30"
|
||||
],
|
||||
"programResults": [
|
||||
"192.168.0.31/notizen.txt: 3 B"
|
||||
],
|
||||
"programFiles": [
|
||||
[
|
||||
"Lied der Woche.txt",
|
||||
"txt",
|
||||
"La la la"
|
||||
]
|
||||
],
|
||||
"dialogs": [
|
||||
"Es sind nur Zahlen erlaubt!"
|
||||
]
|
||||
},
|
||||
{
|
||||
"step": "take 0 peers",
|
||||
"address": {
|
||||
"text": "192.168.0.30",
|
||||
"red": false
|
||||
},
|
||||
"peers": [
|
||||
[
|
||||
"1",
|
||||
"192.168.0.30"
|
||||
]
|
||||
],
|
||||
"search": "notiz",
|
||||
"results": [
|
||||
[
|
||||
"192.168.0.31",
|
||||
"notizen.txt",
|
||||
" 3 B"
|
||||
]
|
||||
],
|
||||
"files": [
|
||||
[
|
||||
"Lied der Woche.txt",
|
||||
"txt"
|
||||
]
|
||||
],
|
||||
"maximum": {
|
||||
"text": "0",
|
||||
"red": false
|
||||
},
|
||||
"programMaximum": 0,
|
||||
"programPeers": [
|
||||
"192.168.0.30"
|
||||
],
|
||||
"programResults": [
|
||||
"192.168.0.31/notizen.txt: 3 B"
|
||||
],
|
||||
"programFiles": [
|
||||
[
|
||||
"Lied der Woche.txt",
|
||||
"txt",
|
||||
"La la la"
|
||||
]
|
||||
],
|
||||
"dialogs": []
|
||||
},
|
||||
{
|
||||
"step": "take a number beyond Java's int",
|
||||
"address": {
|
||||
"text": "192.168.0.30",
|
||||
"red": false
|
||||
},
|
||||
"peers": [
|
||||
[
|
||||
"1",
|
||||
"192.168.0.30"
|
||||
]
|
||||
],
|
||||
"search": "notiz",
|
||||
"results": [
|
||||
[
|
||||
"192.168.0.31",
|
||||
"notizen.txt",
|
||||
" 3 B"
|
||||
]
|
||||
],
|
||||
"files": [
|
||||
[
|
||||
"Lied der Woche.txt",
|
||||
"txt"
|
||||
]
|
||||
],
|
||||
"maximum": {
|
||||
"text": "99999999999",
|
||||
"red": true
|
||||
},
|
||||
"programMaximum": 0,
|
||||
"programPeers": [
|
||||
"192.168.0.30"
|
||||
],
|
||||
"programResults": [
|
||||
"192.168.0.31/notizen.txt: 3 B"
|
||||
],
|
||||
"programFiles": [
|
||||
[
|
||||
"Lied der Woche.txt",
|
||||
"txt",
|
||||
"La la la"
|
||||
]
|
||||
],
|
||||
"dialogs": [
|
||||
"Es sind nur Zahlen erlaubt!"
|
||||
]
|
||||
},
|
||||
{
|
||||
"step": "take 12 peers",
|
||||
"address": {
|
||||
"text": "192.168.0.30",
|
||||
"red": false
|
||||
},
|
||||
"peers": [
|
||||
[
|
||||
"1",
|
||||
"192.168.0.30"
|
||||
]
|
||||
],
|
||||
"search": "notiz",
|
||||
"results": [
|
||||
[
|
||||
"192.168.0.31",
|
||||
"notizen.txt",
|
||||
" 3 B"
|
||||
]
|
||||
],
|
||||
"files": [
|
||||
[
|
||||
"Lied der Woche.txt",
|
||||
"txt"
|
||||
]
|
||||
],
|
||||
"maximum": {
|
||||
"text": "12",
|
||||
"red": true
|
||||
},
|
||||
"programMaximum": 0,
|
||||
"programPeers": [
|
||||
"192.168.0.30"
|
||||
],
|
||||
"programResults": [
|
||||
"192.168.0.31/notizen.txt: 3 B"
|
||||
],
|
||||
"programFiles": [
|
||||
[
|
||||
"Lied der Woche.txt",
|
||||
"txt",
|
||||
"La la la"
|
||||
]
|
||||
],
|
||||
"dialogs": [
|
||||
"Es sind nur Zahlen erlaubt!"
|
||||
]
|
||||
},
|
||||
{
|
||||
"step": "take 4 peers",
|
||||
"address": {
|
||||
"text": "192.168.0.30",
|
||||
"red": false
|
||||
},
|
||||
"peers": [
|
||||
[
|
||||
"1",
|
||||
"192.168.0.30"
|
||||
]
|
||||
],
|
||||
"search": "notiz",
|
||||
"results": [
|
||||
[
|
||||
"192.168.0.31",
|
||||
"notizen.txt",
|
||||
" 3 B"
|
||||
]
|
||||
],
|
||||
"files": [
|
||||
[
|
||||
"Lied der Woche.txt",
|
||||
"txt"
|
||||
]
|
||||
],
|
||||
"maximum": {
|
||||
"text": "4",
|
||||
"red": false
|
||||
},
|
||||
"programMaximum": 4,
|
||||
"programPeers": [
|
||||
"192.168.0.30"
|
||||
],
|
||||
"programResults": [
|
||||
"192.168.0.31/notizen.txt: 3 B"
|
||||
],
|
||||
"programFiles": [
|
||||
[
|
||||
"Lied der Woche.txt",
|
||||
"txt",
|
||||
"La la la"
|
||||
]
|
||||
],
|
||||
"dialogs": []
|
||||
},
|
||||
{
|
||||
"step": "leave the network",
|
||||
"address": {
|
||||
"text": "192.168.0.30",
|
||||
"red": false
|
||||
},
|
||||
"peers": [],
|
||||
"search": "notiz",
|
||||
"results": [
|
||||
[
|
||||
"192.168.0.31",
|
||||
"notizen.txt",
|
||||
" 3 B"
|
||||
]
|
||||
],
|
||||
"files": [
|
||||
[
|
||||
"Lied der Woche.txt",
|
||||
"txt"
|
||||
]
|
||||
],
|
||||
"maximum": {
|
||||
"text": "4",
|
||||
"red": false
|
||||
},
|
||||
"programMaximum": 4,
|
||||
"programPeers": [],
|
||||
"programResults": [
|
||||
"192.168.0.31/notizen.txt: 3 B"
|
||||
],
|
||||
"programFiles": [
|
||||
[
|
||||
"Lied der Woche.txt",
|
||||
"txt",
|
||||
"La la la"
|
||||
]
|
||||
],
|
||||
"dialogs": []
|
||||
},
|
||||
{
|
||||
"step": "a file added beside the window",
|
||||
"address": {
|
||||
"text": "192.168.0.30",
|
||||
"red": false
|
||||
},
|
||||
"peers": [],
|
||||
"search": "notiz",
|
||||
"results": [
|
||||
[
|
||||
"192.168.0.31",
|
||||
"notizen.txt",
|
||||
" 3 B"
|
||||
]
|
||||
],
|
||||
"files": [
|
||||
[
|
||||
"Lied der Woche.txt",
|
||||
"txt"
|
||||
]
|
||||
],
|
||||
"maximum": {
|
||||
"text": "4",
|
||||
"red": false
|
||||
},
|
||||
"programMaximum": 4,
|
||||
"programPeers": [],
|
||||
"programResults": [
|
||||
"192.168.0.31/notizen.txt: 3 B"
|
||||
],
|
||||
"programFiles": [
|
||||
[
|
||||
"Lied der Woche.txt",
|
||||
"txt",
|
||||
"La la la"
|
||||
],
|
||||
[
|
||||
"neu.txt",
|
||||
"txt",
|
||||
"neu"
|
||||
]
|
||||
],
|
||||
"dialogs": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
// The Gnutella program as Java's window uses it, against Java's
|
||||
// (gnutellaWindow.java.json, GnutellaWindowRun) on terminal-net beside the
|
||||
// line of GnutellaRun's story "search": Java's steps played on the engine's
|
||||
// program — joining (not with what is no address, nor itself), searching,
|
||||
// "Stopp!", downloading, emptying the list, the number of peers, leaving —
|
||||
// and after each what the program keeps (its peers, results, files of
|
||||
// /peer2peer, number of peers) and what the window's tables show. Where Java
|
||||
// errs, what the program and window do instead (named at each step): the
|
||||
// tables show what the program keeps at once, a number of peers is not one
|
||||
// digit only, a hit names a text's size in characters (gnutellaJava.test.ts).
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { GNUTELLA_FOLDER, gnutellaPeerCountOfText, gnutellaResultRow, IP_ADDRESS_PATTERN, javaFileTypeName, NetworkRuntimeEngine, VirtualFileSystem, withSnapshot } from '../src/index.js';
|
||||
import { snapshotOf, type Scenario } from './scenario.js';
|
||||
|
||||
interface JavaStep {
|
||||
readonly step: string;
|
||||
readonly address: { readonly text: string; readonly red: boolean };
|
||||
readonly peers: readonly (readonly string[])[];
|
||||
readonly results: readonly (readonly string[])[];
|
||||
readonly files: readonly (readonly string[])[];
|
||||
readonly maximum: { readonly text: string; readonly red: boolean };
|
||||
readonly programMaximum: number;
|
||||
readonly programPeers: readonly string[];
|
||||
readonly programResults: readonly string[];
|
||||
readonly programFiles: readonly (readonly string[])[];
|
||||
readonly dialogs: readonly string[];
|
||||
}
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const java = JSON.parse(readFileSync(join(here, 'gnutellaWindow.java.json'), 'utf8')) as { readonly steps: readonly JavaStep[] };
|
||||
const scenario = JSON.parse(readFileSync(join(here, 'scenarios/terminal-net.scenario.json'), 'utf8')) as Scenario;
|
||||
const CLIENT = '00000000-0000-0000-0000-000000000401';
|
||||
const WEB = '00000000-0000-0000-0000-000000000403';
|
||||
const ECHO = '00000000-0000-0000-0000-000000000404';
|
||||
const FAR = '00000000-0000-0000-0000-000000000406';
|
||||
const PEERS = [CLIENT, WEB, ECHO, FAR];
|
||||
|
||||
/** Each shared text's size in characters, as the program names it in a hit (Java decodes the text as Base64: "La la la" and "Notiz" are 3 B). */
|
||||
const CHARACTERS: Readonly<Record<string, number>> = { 'Lied der Woche.txt': 8, 'notizen.txt': 5 };
|
||||
const inCharacters = (text: string) => text.replace(/([^/]+): 3 B$/, (_, name: string) => `${name}: ${CHARACTERS[name]} B`);
|
||||
|
||||
function setUp() {
|
||||
const files = new Map(PEERS.map((host) => [host, new VirtualFileSystem()]));
|
||||
for (const host of [ECHO, FAR]) files.get(host)!.createDirectory(GNUTELLA_FOLDER);
|
||||
files.get(ECHO)!.writeTextFile(`${GNUTELLA_FOLDER}/notizen.txt`, 'Notiz');
|
||||
files.get(FAR)!.writeTextFile(`${GNUTELLA_FOLDER}/Lied der Woche.txt`, 'La la la');
|
||||
const engine = new NetworkRuntimeEngine(scenario.seed);
|
||||
engine.handle({
|
||||
type: 'start',
|
||||
snapshot: withSnapshot(snapshotOf(scenario), {
|
||||
gnutellaByNodeId: new Map(PEERS.map((host) => [host, { maximumKnownPeers: host === CLIENT || host === FAR ? 1 : 2 }])),
|
||||
fileSystemsByNodeId: files,
|
||||
}),
|
||||
seed: scenario.seed,
|
||||
initialTimeMilliseconds: 0,
|
||||
});
|
||||
const run = (milliseconds: number) => engine.handle({ type: 'advance', toMilliseconds: engine.state.currentTimeMilliseconds + milliseconds });
|
||||
return { engine, files, run };
|
||||
}
|
||||
|
||||
describe("the Gnutella window, against Java's", () => {
|
||||
it("plays Java's steps: the same where Java is right, as named where it errs", () => {
|
||||
const { engine, files, run } = setUp();
|
||||
const program = engine.gnutella(CLIENT)!;
|
||||
const folder = files.get(CLIENT)!;
|
||||
const kept = () => ({
|
||||
peers: program.peers,
|
||||
results: program.results,
|
||||
maximum: program.maximumKnownPeers,
|
||||
files: folder.contains(GNUTELLA_FOLDER)
|
||||
? folder.entriesInCreationOrder(GNUTELLA_FOLDER).map((entry) => {
|
||||
const content = entry.content;
|
||||
if (content.kind !== 'text') throw new Error(`${entry.path} is no text`);
|
||||
const name = entry.path.slice(GNUTELLA_FOLDER.length + 1);
|
||||
return [name, javaFileTypeName(name, content), content.text];
|
||||
})
|
||||
: [],
|
||||
});
|
||||
const at = (name: string) => java.steps.find((step) => step.step === name)!;
|
||||
const javaKept = (step: JavaStep) => ({ peers: step.programPeers, results: step.programResults.map(inCharacters), maximum: step.programMaximum, files: step.programFiles });
|
||||
const check = (name: string) => expect(kept(), name).toEqual(javaKept(at(name)));
|
||||
/** The window's tables as they show what the program keeps (Java's lag behind it, named where they do). */
|
||||
const tables = () => ({
|
||||
peers: program.peers.map((peer, index) => [String(index + 1), peer]),
|
||||
results: program.results.map((result) => [...gnutellaResultRow(result)]),
|
||||
files: kept().files.map(([name, type]) => [name, type]),
|
||||
});
|
||||
const javaTables = (step: JavaStep) => ({ peers: step.peers, results: step.results.map(([address, name]) => [address, name, ` ${CHARACTERS[name!]} B`]), files: step.files });
|
||||
const join = (address: string) => {
|
||||
if (IP_ADDRESS_PATTERN.test(address)) program.join(address);
|
||||
};
|
||||
|
||||
// The others form their line first, as Java's did (the window opens 5.5 s in).
|
||||
run(500);
|
||||
engine.gnutella(WEB)!.join('192.168.0.31');
|
||||
run(2500);
|
||||
engine.gnutella(FAR)!.join('192.168.0.31');
|
||||
run(2500);
|
||||
check('open');
|
||||
expect(at('open').address).toEqual({ text: '192.168.0.1', red: false });
|
||||
expect(at('open').maximum).toEqual({ text: '1', red: false });
|
||||
|
||||
// What is no address: red, and not taken (Java's message).
|
||||
expect([IP_ADDRESS_PATTERN.test('192.168.0.300'), at('type an address that is none').address.red]).toEqual([false, true]);
|
||||
join('192.168.0.300');
|
||||
run(1000);
|
||||
check('join with an address that is none');
|
||||
expect(at('join with an address that is none').dialogs).toEqual(['Dies ist keine gültige IP-Adresse']);
|
||||
join('192.168.0.20');
|
||||
run(2500);
|
||||
check('join itself');
|
||||
join('192.168.0.30');
|
||||
run(3000);
|
||||
check('join 192.168.0.30');
|
||||
expect(tables(), 'join 192.168.0.30').toEqual(javaTables(at('join 192.168.0.30')));
|
||||
|
||||
program.search('woche');
|
||||
run(3000);
|
||||
check('search woche');
|
||||
expect(tables()).toEqual(javaTables(at('search woche')));
|
||||
program.download(0);
|
||||
run(3000);
|
||||
check('download row 0');
|
||||
// The file's table shows it at once (Java's only when the program next tells the window of a change).
|
||||
expect(at('download row 0').files).toEqual([]);
|
||||
expect(tables().files).toEqual([['Lied der Woche.txt', 'txt']]);
|
||||
program.download(0);
|
||||
run(3000);
|
||||
check('download row 0 again');
|
||||
program.search('xyz');
|
||||
run(3000);
|
||||
check('search xyz');
|
||||
// The table shows no result, as the program keeps none (Java's still shows the last search's).
|
||||
expect([at('search xyz').results.length, tables().results]).toEqual([1, []]);
|
||||
program.clearResults();
|
||||
run(500);
|
||||
check('empty the list');
|
||||
|
||||
// "Stopp!" half a second after "Suchen" (as Java's press came): the hits on their way are lost; a new search finds them.
|
||||
program.search('notiz');
|
||||
run(500);
|
||||
program.cancelSearch();
|
||||
run(3000);
|
||||
check('search notiz and abort at once');
|
||||
program.search('notiz');
|
||||
run(3000);
|
||||
check('search notiz again');
|
||||
expect(tables()).toEqual(javaTables(at('search notiz again')));
|
||||
run(2000);
|
||||
check('download with no row chosen');
|
||||
check('files tab');
|
||||
|
||||
// The number of peers: what is no number refused (Java's message), 0 taken; 12 taken too (Java takes one digit only).
|
||||
expect(['abc', '0', '99999999999', '12', '4', '-1', '', '2147483647', '2147483648'].map(gnutellaPeerCountOfText)).toEqual([
|
||||
undefined,
|
||||
0,
|
||||
undefined,
|
||||
12,
|
||||
4,
|
||||
undefined,
|
||||
undefined,
|
||||
2_147_483_647,
|
||||
undefined,
|
||||
]);
|
||||
expect([at('take a number of peers that is none').dialogs, at('take 12 peers').dialogs]).toEqual([['Es sind nur Zahlen erlaubt!'], ['Es sind nur Zahlen erlaubt!']]);
|
||||
program.setMaximumKnownPeers(0);
|
||||
check('take 0 peers');
|
||||
check("take a number beyond Java's int");
|
||||
program.setMaximumKnownPeers(12);
|
||||
expect(program.maximumKnownPeers).toBe(12);
|
||||
program.setMaximumKnownPeers(4);
|
||||
check('take 4 peers');
|
||||
|
||||
program.reset();
|
||||
run(1000);
|
||||
check('leave the network');
|
||||
expect(tables().peers).toEqual([]);
|
||||
// A file added beside the window: its table shows it at once (Java's not).
|
||||
folder.writeTextFile(`${GNUTELLA_FOLDER}/neu.txt`, 'neu');
|
||||
check('a file added beside the window');
|
||||
expect([at('a file added beside the window').files.length, tables().files.length]).toEqual([1, 2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the Gnutella program beyond the steps', () => {
|
||||
it('splits a result as Java’s table does', () => {
|
||||
expect(['10.0.0.2/Lied der Woche.txt: 8 B', '10.0.0.2/a:b.txt: 3 B', '//x/y', 'kein'].map(gnutellaResultRow)).toEqual([
|
||||
['10.0.0.2', 'Lied der Woche.txt', ' 8 B'],
|
||||
['10.0.0.2', 'a:b.txt', ' 3 B'],
|
||||
['x', 'y', ''],
|
||||
['kein', '', ''],
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps the peers it knows when the number shrinks, and takes no more', () => {
|
||||
const { engine, run } = setUp();
|
||||
const program = engine.gnutella(WEB)!;
|
||||
program.setMaximumKnownPeers(1);
|
||||
program.join('192.168.0.31');
|
||||
run(3000);
|
||||
engine.gnutella(CLIENT)!.join('192.168.0.30');
|
||||
run(3000);
|
||||
expect(program.peers).toEqual(['192.168.0.31']);
|
||||
program.setMaximumKnownPeers(0);
|
||||
expect(program.peers).toEqual(['192.168.0.31']);
|
||||
});
|
||||
|
||||
it("cancels only the program's own requests: a query it passes on still brings its hits back", () => {
|
||||
const { engine, run } = setUp();
|
||||
engine.gnutella(WEB)!.join('192.168.0.31');
|
||||
run(3000);
|
||||
engine.gnutella(CLIENT)!.join('192.168.0.30');
|
||||
run(3000);
|
||||
engine.gnutella(CLIENT)!.search('notiz');
|
||||
// .30 has the query (after about 330 ms) and has sent it on to .31 (about 390 ms) when it cancels its own requests
|
||||
// (its join's connection): the hits on their way back through it still come.
|
||||
run(450);
|
||||
engine.gnutella(WEB)!.cancelSearch();
|
||||
run(3000);
|
||||
expect(engine.gnutella(CLIENT)!.results).toEqual(['192.168.0.31/notizen.txt: 5 B']);
|
||||
});
|
||||
|
||||
it('shows the files of /peer2peer, not its folders, as Java lists them', () => {
|
||||
const { engine, files } = setUp();
|
||||
files.get(ECHO)!.createDirectory(`${GNUTELLA_FOLDER}/ordner`);
|
||||
expect(engine.gnutella(ECHO)!.fileRows()).toEqual([['notizen.txt', 'txt']]);
|
||||
expect(engine.gnutella(CLIENT)!.fileRows()).toEqual([]);
|
||||
});
|
||||
|
||||
it('cancels the downloads too, and a cancel without requests changes nothing', () => {
|
||||
const { engine, files, run } = setUp();
|
||||
const program = engine.gnutella(CLIENT)!;
|
||||
program.cancelSearch();
|
||||
program.join('192.168.0.31');
|
||||
run(3000);
|
||||
program.search('notiz');
|
||||
run(3000);
|
||||
expect(program.results).toEqual(['192.168.0.31/notizen.txt: 5 B']);
|
||||
program.download(0);
|
||||
program.cancelSearch();
|
||||
run(3000);
|
||||
expect(files.get(CLIENT)!.contains(`${GNUTELLA_FOLDER}/notizen.txt`)).toBe(false);
|
||||
program.download(0);
|
||||
run(3000);
|
||||
expect(files.get(CLIENT)!.textFile(`${GNUTELLA_FOLDER}/notizen.txt`)).toBe('Notiz');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
// Gnutella on two notebooks' desktops in Chromium (F4.6g4), on FILIUS'
|
||||
// example email_komplett with the program installed during the run: Java's
|
||||
// four tabs, an address refused with Java's words, a peer joined, a file
|
||||
// found on the other notebook and downloaded (the files tab shows it), the
|
||||
// number of peers saved (also one Java refuses, and what is no number), the
|
||||
// network left.
|
||||
|
||||
import { expect, test, type Locator, type Page } from '@playwright/test';
|
||||
import { drawing, onDevice, openEditor, serveEditor } from './editorPage.js';
|
||||
|
||||
let server: { readonly base: string; readonly close: () => Promise<void> };
|
||||
|
||||
test.beforeAll(async () => {
|
||||
server = await serveEditor();
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await server.close();
|
||||
});
|
||||
|
||||
async function idOf(page: Page, name: string) {
|
||||
return (await drawing(page)).nodes.find((node) => node.name === name)!.id;
|
||||
}
|
||||
|
||||
async function desktopOf(page: Page, name: string) {
|
||||
const id = await idOf(page, name);
|
||||
const at = await onDevice(page, id);
|
||||
await page.mouse.click(at.x, at.y);
|
||||
return page.locator(`section[data-desktop="${id}"]`);
|
||||
}
|
||||
|
||||
async function install(desktop: Locator, program: string) {
|
||||
await desktop.locator('[data-program="installation"]').click();
|
||||
const installation = desktop.locator('[data-program-window="installation"]');
|
||||
await installation.locator('[data-list="available"] [role="option"]').filter({ hasText: program }).click();
|
||||
await installation.getByRole('button', { name: 'Installieren' }).click();
|
||||
await installation.getByRole('button', { name: 'Änderungen annehmen' }).click();
|
||||
}
|
||||
|
||||
test('joins, searches and downloads as Java’s Gnutella window does', async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await openEditor(page, server.base, 'email_komplett.fls');
|
||||
await page.getByRole('button', { name: 'Wechselt in den Aktionsmodus' }).click();
|
||||
await expect(page.locator('svg.filius-canvas')).toHaveAttribute('data-mode', 'simulation');
|
||||
// The other notebook shares a file.
|
||||
const other = await desktopOf(page, 'Rechner 2');
|
||||
await install(other, 'Gnutella');
|
||||
await expect(other.locator('[data-program="gnutella"]')).toBeVisible();
|
||||
const otherId = await idOf(page, 'Rechner 2');
|
||||
await page.evaluate(
|
||||
(id) => (window as unknown as { filiusDev: { writeFile: (id: string, path: string, text: string) => void } }).filiusDev.writeFile(id, '/peer2peer/lied.txt', 'La la la'),
|
||||
otherId,
|
||||
);
|
||||
await other.locator('.desktop-close').click();
|
||||
|
||||
const desktop = await desktopOf(page, 'Rechner 1');
|
||||
await install(desktop, 'Gnutella');
|
||||
await desktop.locator('[data-program="gnutella"]').click();
|
||||
const window = desktop.locator('[data-program-window="gnutella"]');
|
||||
await expect(window.locator('.program-titlebar h3')).toHaveText('Gnutella');
|
||||
await expect(window.getByRole('tab')).toHaveText(['Netzwerk', 'Suche', 'Dateien', 'Einstellungen']);
|
||||
|
||||
// What is no address: red, and Java's words.
|
||||
const address = window.getByRole('textbox', { name: 'Teilnehmer IP-Adresse:' });
|
||||
await expect(address).toHaveValue('192.168.0.1');
|
||||
await address.fill('141.99.50.300');
|
||||
await expect(address).toHaveAttribute('aria-invalid', 'true');
|
||||
await window.getByRole('button', { name: 'Netz beitreten' }).click();
|
||||
await expect(window.getByRole('alert')).toHaveText('Dies ist keine gültige IP-Adresse');
|
||||
await address.fill('141.99.50.182');
|
||||
await window.getByRole('button', { name: 'Netz beitreten' }).click();
|
||||
const peers = window.getByRole('table', { name: 'Liste der verbundenen Nachbarn:' }).locator('tbody tr');
|
||||
await expect(peers).toHaveCount(1, { timeout: 30_000 });
|
||||
await expect(peers.first().locator('td')).toHaveText(['1', '141.99.50.182']);
|
||||
|
||||
// The other's file found and downloaded.
|
||||
await window.getByRole('tab', { name: 'Suche' }).click();
|
||||
await window.getByRole('textbox', { name: 'Suchbegriff:' }).fill('lied');
|
||||
await window.getByRole('button', { name: 'Suchen' }).click();
|
||||
const results = window.getByRole('table', { name: 'Suche' }).locator('tbody tr');
|
||||
await expect(results).toHaveCount(1, { timeout: 30_000 });
|
||||
await expect(results.first().locator('td')).toHaveText(['141.99.50.182', 'lied.txt', '8 B']);
|
||||
const download = window.getByRole('button', { name: 'Herunterladen' });
|
||||
await expect(download).toBeDisabled();
|
||||
await results.first().dispatchEvent('pointerdown');
|
||||
await download.click();
|
||||
// The list emptied: nothing is chosen any more.
|
||||
await window.getByRole('button', { name: 'Liste leeren' }).click();
|
||||
await expect(results).toHaveCount(0);
|
||||
await expect(download).toBeDisabled();
|
||||
await window.getByRole('tab', { name: 'Dateien' }).click();
|
||||
await expect(window.getByRole('table', { name: 'Dateien' }).locator('tbody tr td')).toHaveText(['lied.txt', 'txt'], { timeout: 30_000 });
|
||||
|
||||
// The number of peers: 12 saved (Java takes one digit only), what is no number refused with Java's words.
|
||||
await window.getByRole('tab', { name: 'Einstellungen' }).click();
|
||||
const maximum = window.getByRole('textbox', { name: 'Maximale Anzahl direkter Nachbarn:' });
|
||||
await expect(maximum).toHaveValue('3');
|
||||
await maximum.fill('012');
|
||||
await window.getByRole('button', { name: 'Speichern' }).click();
|
||||
// The field shows the program's number once it is saved.
|
||||
await expect(maximum).toHaveValue('12');
|
||||
await expect(window.getByRole('alert')).toHaveCount(0);
|
||||
await maximum.fill('x');
|
||||
await expect(maximum).toHaveAttribute('aria-invalid', 'true');
|
||||
await window.getByRole('button', { name: 'Speichern' }).click();
|
||||
await expect(window.getByRole('alert')).toHaveText('Es sind nur Zahlen erlaubt!');
|
||||
|
||||
// The network left.
|
||||
await window.getByRole('tab', { name: 'Netzwerk' }).click();
|
||||
await window.getByRole('button', { name: 'Netz verlassen' }).click();
|
||||
await expect(peers).toHaveCount(0);
|
||||
});
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 864 B |
Binary file not shown.
|
After Width: | Height: | Size: 4.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.0 KiB |
@@ -42,6 +42,7 @@ import { FileExplorerPanel } from './FileExplorerPanel.js';
|
||||
import { EchoServerPanel, SimpleClientPanel } from './EchoPanels.js';
|
||||
import { DnsServerPanel } from './DnsServerPanel.js';
|
||||
import { PersonalFirewallPanel } from './PersonalFirewallPanel.js';
|
||||
import { GnutellaPanel } from './GnutellaPanel.js';
|
||||
import { ImageViewerPanel, useImageViewer } from './ImageViewerPanel.js';
|
||||
import { WebServerPanel } from './WebServerPanel.js';
|
||||
import './desktop.css';
|
||||
@@ -72,7 +73,7 @@ export interface DesktopWindowProps {
|
||||
export type DesktopWindowName = DesktopProgram | 'installation';
|
||||
|
||||
/** The programs whose windows are built so far. */
|
||||
const WITH_WINDOW: ReadonlySet<DesktopProgram> = new Set(['commandPrompt', 'webBrowser', 'webServer', 'emailClient', 'emailServer', 'textEditor', 'fileExplorer', 'imageViewer', 'echoServer', 'simpleClient', 'dnsServer', 'firewall']);
|
||||
const WITH_WINDOW: ReadonlySet<DesktopProgram> = new Set(['commandPrompt', 'webBrowser', 'webServer', 'emailClient', 'emailServer', 'textEditor', 'fileExplorer', 'imageViewer', 'echoServer', 'simpleClient', 'dnsServer', 'firewall', 'gnutella']);
|
||||
|
||||
/** The document's desktop of a computer, read anew after every change (a program installed, the label changed). */
|
||||
function useDesktopView(doc: Y.Doc, id: string) {
|
||||
@@ -191,6 +192,7 @@ export function DesktopWindow({ doc, id, simulation, active, x, y, z, program: o
|
||||
{shown === 'simpleClient' ? <SimpleClientPanel node={id} simulation={simulation} /> : null}
|
||||
{shown === 'dnsServer' ? <DnsServerPanel node={id} simulation={simulation} /> : null}
|
||||
{shown === 'firewall' ? <PersonalFirewallPanel node={id} simulation={simulation} /> : null}
|
||||
{shown === 'gnutella' ? <GnutellaPanel node={id} simulation={simulation} /> : null}
|
||||
</ProgramFrame>
|
||||
)}
|
||||
{info ? (
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
// A computer's Gnutella program, after Java's
|
||||
// GUIApplicationPeerToPeerAnwendungWindow (measured with tools/java-oracle's
|
||||
// GnutellaWindowRun): four tabs with Java's symbols. "Netzwerk": a peer's
|
||||
// address (it starts as Java's, 192.168.0.1; red while it is no address)
|
||||
// with "Netz beitreten", the table of known peers, "Netz verlassen".
|
||||
// "Suche": the words with "Suchen" and "Stopp!", the table of results (a row
|
||||
// chosen), "Herunterladen" and "Liste leeren". "Dateien": the files of
|
||||
// /peer2peer. "Einstellungen": how many peers it keeps at most, with
|
||||
// "Speichern". The program is the room's: the panel shows its view and hands
|
||||
// over what is done.
|
||||
//
|
||||
// Better than Java:
|
||||
// - the tables show what the program keeps at once: Java's results still
|
||||
// show the last search's after a new one until a hit comes, and its files
|
||||
// only what was there when the program last told the window of a change.
|
||||
// - any whole number of peers is taken (Java's check takes one digit only).
|
||||
// - what is not taken is said under its field (Java opens a dialog), and
|
||||
// "Herunterladen" can be pressed only with a result chosen.
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { gnutellaPeerCountOfText, IP_ADDRESS_PATTERN } from '@mdg-filius/core';
|
||||
import type { GnutellaView } from '@mdg-filius/protocol';
|
||||
import networkIcon from '../assets/desktop/peertopeer_netzwerk_klein.png';
|
||||
import searchIcon from '../assets/desktop/peertopeer_suchen_klein.png';
|
||||
import filesIcon from '../assets/desktop/peertopeer_dateien_klein.png';
|
||||
import settingsIcon from '../assets/desktop/peertopeer_einstellungen.png';
|
||||
import { useTranslate } from '../i18n/i18n.js';
|
||||
import type { Simulation } from '../simulation/useSimulation.js';
|
||||
|
||||
type Tab = 'network' | 'search' | 'files' | 'settings';
|
||||
const TABS: readonly (readonly [Tab, string, string])[] = [
|
||||
['network', 'gnutella.networkTab', networkIcon],
|
||||
['search', 'gnutella.searchTab', searchIcon],
|
||||
['files', 'gnutella.filesTab', filesIcon],
|
||||
['settings', 'gnutella.settingsTab', settingsIcon],
|
||||
];
|
||||
|
||||
export function GnutellaPanel({ node, simulation }: { readonly node: string; readonly simulation: Simulation }) {
|
||||
const t = useTranslate();
|
||||
const topic = `app:${node}/gnutella` as const;
|
||||
const { watch, act } = simulation;
|
||||
useEffect(() => watch([topic]), [watch, topic]);
|
||||
const view = simulation.views.get(topic) as GnutellaView | null | undefined;
|
||||
const [tab, setTab] = useState<Tab>('network');
|
||||
const [address, setAddress] = useState('192.168.0.1');
|
||||
const [criteria, setCriteria] = useState('');
|
||||
const [chosen, setChosen] = useState<number | null>(null);
|
||||
const [maximum, setMaximum] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const results = JSON.stringify(view?.results ?? []);
|
||||
// Java's table is filled anew with each change and loses its choice.
|
||||
useEffect(() => setChosen(null), [results]);
|
||||
if (view === null || view === undefined) return <div className="web-server" />;
|
||||
const addressValid = IP_ADDRESS_PATTERN.test(address);
|
||||
const maximumText = maximum ?? String(view.maximumKnownPeers);
|
||||
const count = gnutellaPeerCountOfText(maximumText);
|
||||
|
||||
const join = () => {
|
||||
if (!addressValid) {
|
||||
setNotice(t('gnutella.invalidAddress'));
|
||||
return;
|
||||
}
|
||||
act(node, 'gnutella', 'join', { address });
|
||||
setNotice(null);
|
||||
};
|
||||
const search = () => act(node, 'gnutella', 'search', { criteria });
|
||||
const save = () => {
|
||||
if (count === undefined) {
|
||||
setNotice(t('gnutella.onlyNumbers'));
|
||||
return;
|
||||
}
|
||||
act(node, 'gnutella', 'setMaximumPeers', { count });
|
||||
setMaximum(null);
|
||||
setNotice(null);
|
||||
};
|
||||
const noticeLine =
|
||||
notice === null ? null : (
|
||||
<p className="mail-notice" role="alert">
|
||||
{notice}
|
||||
</p>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="web-server gnutella">
|
||||
<div className="mail-tabs" role="tablist">
|
||||
{TABS.map(([each, label, icon]) => (
|
||||
<button
|
||||
key={each}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === each}
|
||||
onClick={() => {
|
||||
setTab(each);
|
||||
setNotice(null);
|
||||
}}
|
||||
>
|
||||
<img src={icon} alt="" />
|
||||
{t(label)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{tab === 'network' ? (
|
||||
<div className="gnutella-page" role="tabpanel" aria-label={t('gnutella.networkTab')}>
|
||||
<div className="web-server-bar">
|
||||
<label>
|
||||
{t('gnutella.address')}
|
||||
<input
|
||||
value={address}
|
||||
inputMode="decimal"
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
aria-invalid={addressValid ? undefined : 'true'}
|
||||
onChange={(event) => {
|
||||
setAddress(event.target.value);
|
||||
setNotice(null);
|
||||
}}
|
||||
onKeyDown={(event) => event.key === 'Enter' && join()}
|
||||
/>
|
||||
</label>
|
||||
<button type="button" title={t('gnutella.joinHint')} onClick={join}>
|
||||
{t('gnutella.join')}
|
||||
</button>
|
||||
</div>
|
||||
{noticeLine}
|
||||
<p className="gnutella-label">{t('gnutella.neighbours')}</p>
|
||||
<table className="dns-server-table gnutella-table" aria-label={t('gnutella.neighbours')}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('gnutella.number')}</th>
|
||||
<th>{t('gnutella.addressColumn')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{view.peers.map((peer, index) => (
|
||||
<tr key={peer}>
|
||||
<td>{index + 1}</td>
|
||||
<td>{peer}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="web-server-bar">
|
||||
<button type="button" title={t('gnutella.leaveHint')} onClick={() => act(node, 'gnutella', 'leave')}>
|
||||
{t('gnutella.leave')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : tab === 'search' ? (
|
||||
<div className="gnutella-page" role="tabpanel" aria-label={t('gnutella.searchTab')}>
|
||||
<div className="web-server-bar">
|
||||
<label>
|
||||
{t('gnutella.searchTerm')}
|
||||
<input
|
||||
value={criteria}
|
||||
title={t('gnutella.searchHint')}
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
onChange={(event) => setCriteria(event.target.value)}
|
||||
onKeyDown={(event) => event.key === 'Enter' && search()}
|
||||
/>
|
||||
</label>
|
||||
<button type="button" onClick={search}>
|
||||
{t('gnutella.search')}
|
||||
</button>
|
||||
<button type="button" title={t('gnutella.stopHint')} onClick={() => act(node, 'gnutella', 'cancelSearch')}>
|
||||
{t('gnutella.stop')}
|
||||
</button>
|
||||
</div>
|
||||
<table className="dns-server-table gnutella-table" aria-label={t('gnutella.searchTab')}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('gnutella.resultAddress')}</th>
|
||||
<th>{t('gnutella.resultName')}</th>
|
||||
<th>{t('gnutella.resultSize')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{view.results.map((cells, row) => (
|
||||
<tr
|
||||
key={row}
|
||||
tabIndex={0}
|
||||
aria-selected={row === chosen}
|
||||
onPointerDown={() => setChosen(row)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||||
event.preventDefault();
|
||||
setChosen(row);
|
||||
}}
|
||||
>
|
||||
{cells.map((cell, column) => (
|
||||
<td key={column}>{cell}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="web-server-bar">
|
||||
<button type="button" title={t('gnutella.downloadHint')} disabled={chosen === null} onClick={() => chosen !== null && act(node, 'gnutella', 'download', { row: chosen })}>
|
||||
{t('gnutella.download')}
|
||||
</button>
|
||||
<button type="button" title={t('gnutella.clearHint')} onClick={() => act(node, 'gnutella', 'clearResults')}>
|
||||
{t('gnutella.clear')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : tab === 'files' ? (
|
||||
<div className="gnutella-page" role="tabpanel" aria-label={t('gnutella.filesTab')}>
|
||||
<p className="gnutella-label">{t('gnutella.files')}</p>
|
||||
<table className="dns-server-table gnutella-table" aria-label={t('gnutella.filesTab')}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('gnutella.fileName')}</th>
|
||||
<th>{t('gnutella.fileType')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{view.files.map(([name, type]) => (
|
||||
<tr key={name}>
|
||||
<td>{name}</td>
|
||||
<td>{type}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="gnutella-page" role="tabpanel" aria-label={t('gnutella.settingsTab')}>
|
||||
<div className="web-server-bar">
|
||||
<label>
|
||||
{t('gnutella.maximum')}
|
||||
<input
|
||||
value={maximumText}
|
||||
inputMode="numeric"
|
||||
aria-invalid={count === undefined ? 'true' : undefined}
|
||||
onChange={(event) => {
|
||||
setMaximum(event.target.value);
|
||||
setNotice(null);
|
||||
}}
|
||||
onKeyDown={(event) => event.key === 'Enter' && save()}
|
||||
/>
|
||||
</label>
|
||||
<button type="button" onClick={save}>
|
||||
{t('gnutella.save')}
|
||||
</button>
|
||||
</div>
|
||||
{noticeLine}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1202,3 +1202,52 @@
|
||||
.personal-firewall-table tr {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Gnutella (GUIApplicationPeerToPeerAnwendungWindow): four tabs with Java's
|
||||
* symbols — the network, the search, the files, the settings. */
|
||||
.gnutella-page {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.gnutella .mail-tabs button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.gnutella .mail-tabs img {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.gnutella-label {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.gnutella input {
|
||||
min-height: 32px;
|
||||
padding: 0 6px;
|
||||
border: 1px solid #a0a0a0;
|
||||
background: #ffffff;
|
||||
color: #000000;
|
||||
font: 16px Dialog, sans-serif;
|
||||
}
|
||||
|
||||
.gnutella [aria-invalid='true'] {
|
||||
border-color: #ff0000;
|
||||
color: #ff1414;
|
||||
}
|
||||
|
||||
.gnutella-table td {
|
||||
padding: 6px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.gnutella-table tbody tr[tabindex] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -220,6 +220,37 @@
|
||||
"forwarding.lanPortValue": "LAN-Port",
|
||||
"forwarding.protocolValue": "Protokoll",
|
||||
"forwarding.publicPortValue": "Portfreigabe",
|
||||
"gnutella.address": "Teilnehmer IP-Adresse:",
|
||||
"gnutella.addressColumn": "Adresse",
|
||||
"gnutella.clear": "Liste leeren",
|
||||
"gnutella.clearHint": "Entfernt die bisherigen Suchergebnisse",
|
||||
"gnutella.download": "Herunterladen",
|
||||
"gnutella.downloadHint": "Herunterladen der markierten Datei",
|
||||
"gnutella.fileName": "Dateiname",
|
||||
"gnutella.fileType": "Dateityp",
|
||||
"gnutella.files": "Dateien:",
|
||||
"gnutella.filesTab": "Dateien",
|
||||
"gnutella.invalidAddress": "Dies ist keine gültige IP-Adresse",
|
||||
"gnutella.join": "Netz beitreten",
|
||||
"gnutella.joinHint": "Versucht einem bestehenden Netzwerk beizutreten, nach Eingabe der IP-Adresse eines Teilnehmers.",
|
||||
"gnutella.leave": "Netz verlassen",
|
||||
"gnutella.leaveHint": "Zurücksetzen der Teilnehmerliste",
|
||||
"gnutella.maximum": "Maximale Anzahl direkter Nachbarn:",
|
||||
"gnutella.neighbours": "Liste der verbundenen Nachbarn:",
|
||||
"gnutella.networkTab": "Netzwerk",
|
||||
"gnutella.number": "Kennung",
|
||||
"gnutella.onlyNumbers": "Es sind nur Zahlen erlaubt!",
|
||||
"gnutella.resultAddress": "IP-Adresse",
|
||||
"gnutella.resultName": "Dateiname",
|
||||
"gnutella.resultSize": "Größe",
|
||||
"gnutella.save": "Speichern",
|
||||
"gnutella.search": "Suchen",
|
||||
"gnutella.searchHint": "Suche nach enthaltener Zeichenkette",
|
||||
"gnutella.searchTab": "Suche",
|
||||
"gnutella.searchTerm": "Suchbegriff:",
|
||||
"gnutella.settingsTab": "Einstellungen",
|
||||
"gnutella.stop": "Stopp!",
|
||||
"gnutella.stopHint": "Stoppt die laufende Suche",
|
||||
"installation.add": "Installieren",
|
||||
"installation.apply": "Änderungen annehmen",
|
||||
"installation.available": "Verfügbar:",
|
||||
|
||||
@@ -220,6 +220,37 @@
|
||||
"forwarding.lanPortValue": "LAN Port",
|
||||
"forwarding.protocolValue": "Protocol",
|
||||
"forwarding.publicPortValue": "Mapped Port",
|
||||
"gnutella.address": "Member IP address:",
|
||||
"gnutella.addressColumn": "address",
|
||||
"gnutella.clear": "Clear list",
|
||||
"gnutella.clearHint": "Clear results",
|
||||
"gnutella.download": "Download",
|
||||
"gnutella.downloadHint": "Downloads selected file",
|
||||
"gnutella.fileName": "File",
|
||||
"gnutella.fileType": "File type",
|
||||
"gnutella.files": "Files:",
|
||||
"gnutella.filesTab": "Files",
|
||||
"gnutella.invalidAddress": "Invalid IP address.",
|
||||
"gnutella.join": "Enter network",
|
||||
"gnutella.joinHint": "Enter an existing network.",
|
||||
"gnutella.leave": "Leave network",
|
||||
"gnutella.leaveHint": "Clear list of connected neighbours.",
|
||||
"gnutella.maximum": "Maximal number of direct neighbours:",
|
||||
"gnutella.neighbours": "List of connected neighbours.",
|
||||
"gnutella.networkTab": "Network",
|
||||
"gnutella.number": "Identification",
|
||||
"gnutella.onlyNumbers": "Just numbers are allowed!",
|
||||
"gnutella.resultAddress": "IP address",
|
||||
"gnutella.resultName": "File",
|
||||
"gnutella.resultSize": "Size",
|
||||
"gnutella.save": "Save",
|
||||
"gnutella.search": "Search",
|
||||
"gnutella.searchHint": "Search for substring",
|
||||
"gnutella.searchTab": "Search",
|
||||
"gnutella.searchTerm": "Search item:",
|
||||
"gnutella.settingsTab": "Settings",
|
||||
"gnutella.stop": "Stop!",
|
||||
"gnutella.stopHint": "Stop current search",
|
||||
"installation.add": "Install",
|
||||
"installation.apply": "Apply changes",
|
||||
"installation.available": "Available:",
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import { StrictMode, useEffect, useState } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import * as Y from 'yjs';
|
||||
import { desktopView, deviceSheet, emptyProject, fileText, topologyView, writeProject } from '@mdg-filius/doc';
|
||||
import { changeFiles, desktopView, deviceSheet, emptyProject, fileText, topologyView, writeProject } from '@mdg-filius/doc';
|
||||
import { openProjectArchive } from '@mdg-filius/fls';
|
||||
import { startBridge } from './bridge/dashboardBridge.js';
|
||||
import { Editor } from './Editor.js';
|
||||
@@ -70,6 +70,7 @@ function App() {
|
||||
sheet: (id: string) => deviceSheet(doc, id),
|
||||
desktop: (id: string) => desktopView(doc, id),
|
||||
insertText: (id: string, path: string, index: number, text: string) => doc.transact(() => fileText(doc, id, path)?.insert(index, text), 'other'),
|
||||
writeFile: (id: string, path: string, text: string) => changeFiles(doc, id, 'other', (files) => files.writeTextFile(path, text)),
|
||||
};
|
||||
}, [doc]);
|
||||
|
||||
|
||||
@@ -175,6 +175,37 @@ const FROM_JAVA: Readonly<Record<string, string>> = {
|
||||
'firewall.dropIcmp': 'jfirewalldialog_msg40',
|
||||
'firewall.dropIcmpHint': 'jfirewalldialog_msg41',
|
||||
'firewall.filterSyn': 'jfirewalldialog_msg42',
|
||||
'gnutella.address': 'peertopeeranwendung_msg1',
|
||||
'gnutella.join': 'peertopeeranwendung_msg2',
|
||||
'gnutella.joinHint': 'peertopeeranwendung_msg3',
|
||||
'gnutella.neighbours': 'peertopeeranwendung_msg4',
|
||||
'gnutella.invalidAddress': 'peertopeeranwendung_msg5',
|
||||
'gnutella.number': 'peertopeeranwendung_msg6',
|
||||
'gnutella.addressColumn': 'peertopeeranwendung_msg7',
|
||||
'gnutella.resultAddress': 'peertopeeranwendung_msg8',
|
||||
'gnutella.resultName': 'peertopeeranwendung_msg9',
|
||||
'gnutella.resultSize': 'peertopeeranwendung_msg10',
|
||||
'gnutella.searchTerm': 'peertopeeranwendung_msg11',
|
||||
'gnutella.searchHint': 'peertopeeranwendung_msg12',
|
||||
'gnutella.search': 'peertopeeranwendung_msg13',
|
||||
'gnutella.stop': 'peertopeeranwendung_msg14',
|
||||
'gnutella.stopHint': 'peertopeeranwendung_msg15',
|
||||
'gnutella.clear': 'peertopeeranwendung_msg16',
|
||||
'gnutella.clearHint': 'peertopeeranwendung_msg17',
|
||||
'gnutella.download': 'peertopeeranwendung_msg18',
|
||||
'gnutella.downloadHint': 'peertopeeranwendung_msg19',
|
||||
'gnutella.searchTab': 'peertopeeranwendung_msg20',
|
||||
'gnutella.files': 'peertopeeranwendung_msg21',
|
||||
'gnutella.fileName': 'peertopeeranwendung_msg22',
|
||||
'gnutella.fileType': 'peertopeeranwendung_msg23',
|
||||
'gnutella.filesTab': 'peertopeeranwendung_msg24',
|
||||
'gnutella.maximum': 'peertopeeranwendung_msg25',
|
||||
'gnutella.save': 'peertopeeranwendung_msg26',
|
||||
'gnutella.onlyNumbers': 'peertopeeranwendung_msg27',
|
||||
'gnutella.settingsTab': 'peertopeeranwendung_msg28',
|
||||
'gnutella.networkTab': 'peertopeeranwendung_msg29',
|
||||
'gnutella.leave': 'peertopeeranwendung_msg30',
|
||||
'gnutella.leaveHint': 'peertopeeranwendung_msg31',
|
||||
'personalFirewall.hint': 'firewall_msg2',
|
||||
'personalFirewall.port': 'firewall_msg3',
|
||||
'personalFirewall.allSenders': 'firewall_msg4',
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
// What the Gnutella window shows, as the room keeps it and every client shows
|
||||
// it (plan §7.3), after GUIApplicationPeerToPeerAnwendungWindow (measured
|
||||
// with tools/java-oracle's GnutellaWindowRun): its known peers, its search
|
||||
// results, the files of /peer2peer, and how many peers it keeps at most.
|
||||
|
||||
export interface GnutellaView {
|
||||
/** The known peers' addresses, in the order they came. */
|
||||
readonly peers: readonly string[];
|
||||
/** Each result as the table shows it: the address, the file's name, its size (" 8 B", with Java's space). */
|
||||
readonly results: readonly (readonly [string, string, string])[];
|
||||
/** The files of /peer2peer: name and type. */
|
||||
readonly files: readonly (readonly [string, string])[];
|
||||
readonly maximumKnownPeers: number;
|
||||
}
|
||||
@@ -5,5 +5,6 @@ export * from './terminal.js';
|
||||
export * from './dns.js';
|
||||
export * from './echo.js';
|
||||
export * from './firewall.js';
|
||||
export * from './gnutella.js';
|
||||
export * from './mail.js';
|
||||
export * from './web.js';
|
||||
|
||||
@@ -49,7 +49,10 @@ import {
|
||||
NetworkRuntimeEngine,
|
||||
captureRows,
|
||||
firewallRuleText,
|
||||
gnutellaPeerCountOfText,
|
||||
gnutellaResultRow,
|
||||
interfaceKey,
|
||||
IP_ADDRESS_PATTERN,
|
||||
portRuleSender,
|
||||
stableGeneratedMacAddress,
|
||||
type CapturedFrame,
|
||||
@@ -76,6 +79,7 @@ import {
|
||||
type DnsServerView,
|
||||
type EchoServerView,
|
||||
type FirewallView,
|
||||
type GnutellaView,
|
||||
type SimpleClientView,
|
||||
type TraceFrame,
|
||||
type WebServerView,
|
||||
@@ -218,7 +222,8 @@ export class SimulationRoom {
|
||||
* What the run left of each node that Java's project keeps: its files (when
|
||||
* they changed since the last write), whether its servers run, a web
|
||||
* server's virtual hosts, an e-mail server's domain and accounts, an
|
||||
* e-mail program's account and folders, a firewall, a DHCP client's address.
|
||||
* e-mail program's account and folders, a firewall, Gnutella's number of
|
||||
* peers, a DHCP client's address.
|
||||
*/
|
||||
private runState(): Map<Id, NodeRunState> {
|
||||
const snapshot = this.engine.state.topologySnapshot;
|
||||
@@ -256,6 +261,8 @@ export class SimulationRoom {
|
||||
}
|
||||
const client = this.engine.simpleClient(id);
|
||||
if (client !== undefined) settings.simpleClient = { targetAddress: client.targetAddress, targetPort: client.targetPort };
|
||||
const gnutella = this.engine.gnutella(id);
|
||||
if (gnutella !== undefined) settings.gnutella = { maximumKnownPeers: gnutella.maximumKnownPeers };
|
||||
settings.emailClient = snapshot.emailClientsByNodeId.get(id);
|
||||
settings.firewall = snapshot.firewallConfigurationsByNodeId.get(id);
|
||||
if (snapshot.dhcpClientConfigurationsByNodeId.get(id)?.isEnabled === true) settings.device = snapshot.deviceConfigurations.get(id);
|
||||
@@ -468,6 +475,8 @@ export class SimulationRoom {
|
||||
return this.actInDnsServer(input);
|
||||
case 'firewall':
|
||||
return this.actInFirewall(input);
|
||||
case 'gnutella':
|
||||
return this.actInGnutella(input);
|
||||
default:
|
||||
return 'unknownAction';
|
||||
}
|
||||
@@ -503,6 +512,44 @@ export class SimulationRoom {
|
||||
}
|
||||
}
|
||||
|
||||
/** The Gnutella window: "Netz beitreten" with the address, "Netz verlassen", "Suchen" with the words, "Stopp!", "Liste leeren", "Herunterladen" of the row chosen, the number of peers saved. */
|
||||
private actInGnutella(input: Extract<ClientMessage, { kind: 'input' }>): ErrorCode | undefined {
|
||||
const peer = this.engine.gnutella(input.node);
|
||||
if (peer === undefined) return 'unknownApp';
|
||||
const { args } = input;
|
||||
switch (input.action) {
|
||||
case 'join':
|
||||
if (typeof args.address !== 'string' || !IP_ADDRESS_PATTERN.test(args.address)) return 'badArgs';
|
||||
peer.join(args.address);
|
||||
return undefined;
|
||||
case 'leave':
|
||||
peer.reset();
|
||||
return undefined;
|
||||
case 'search':
|
||||
if (typeof args.criteria !== 'string') return 'badArgs';
|
||||
peer.search(args.criteria);
|
||||
return undefined;
|
||||
case 'cancelSearch':
|
||||
peer.cancelSearch();
|
||||
return undefined;
|
||||
case 'clearResults':
|
||||
peer.clearResults();
|
||||
return undefined;
|
||||
case 'download':
|
||||
if (!Number.isInteger(args.row) || (args.row as number) < 0 || (args.row as number) >= peer.results.length) return 'badArgs';
|
||||
peer.download(args.row as number);
|
||||
return undefined;
|
||||
case 'setMaximumPeers': {
|
||||
const count = Number.isInteger(args.count) ? gnutellaPeerCountOfText(String(args.count)) : undefined;
|
||||
if (count === undefined) return 'badArgs';
|
||||
peer.setMaximumKnownPeers(count);
|
||||
return undefined;
|
||||
}
|
||||
default:
|
||||
return 'unknownAction';
|
||||
}
|
||||
}
|
||||
|
||||
/** A computer's firewall window: its three boxes, "Hinzufügen" with the port, the protocol and the senders chosen, "Löschen" of the row chosen. */
|
||||
private actInFirewall(input: Extract<ClientMessage, { kind: 'input' }>): ErrorCode | undefined {
|
||||
const configuration = this.engine.personalFirewall(input.node);
|
||||
@@ -715,6 +762,12 @@ export class SimulationRoom {
|
||||
const view: DnsServerView = { active: server.isActive, recursive: server.recursiveResolutionEnabled, rows: { A: rows('A'), MX: rows('MX'), NS: rows('NS') } };
|
||||
return view;
|
||||
}
|
||||
case 'gnutella': {
|
||||
const peer = this.engine.gnutella(node);
|
||||
if (peer === undefined) return null;
|
||||
const view: GnutellaView = { peers: peer.peers, results: peer.results.map(gnutellaResultRow), files: peer.fileRows(), maximumKnownPeers: peer.maximumKnownPeers };
|
||||
return view;
|
||||
}
|
||||
case 'firewall': {
|
||||
const configuration = this.engine.personalFirewall(node);
|
||||
if (configuration === undefined) return null;
|
||||
@@ -817,8 +870,8 @@ export class SimulationRoom {
|
||||
client.viewSent.set(topic, key);
|
||||
client.send(message({ kind: 'view', node: node!, app: app as AppKind, view }));
|
||||
}
|
||||
// The browser's lines are its view; the DNS server's window has none.
|
||||
if (app === 'webBrowser' || app === 'dnsServer') continue;
|
||||
// The browser's lines are its view; the DNS server's and Gnutella's windows have none.
|
||||
if (app === 'webBrowser' || app === 'dnsServer' || app === 'gnutella') continue;
|
||||
}
|
||||
const sent = client.appSent.get(topic);
|
||||
if (app === 'terminal') {
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
// The Gnutella window in the room, on three computers of one network: its
|
||||
// view (the known peers, the results as the table shows them, the files of
|
||||
// /peer2peer, the number of peers), its actions, a downloaded file in the
|
||||
// document, and what a run leaves in the project. The program's steps
|
||||
// against Java's window are packages/core/test/gnutellaWindow.test.ts.
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import * as Y from 'yjs';
|
||||
import { addDevice, changeFiles, connect, emptyProject, fileText, readProject, setHostAddress, setSoftware, writeProject } from '@mdg-filius/doc';
|
||||
import type { GnutellaView, HostMessage } from '@mdg-filius/protocol';
|
||||
import { SimulationRoom, TICK_MILLISECONDS } from '../src/index.js';
|
||||
|
||||
function setUp() {
|
||||
const doc = new Y.Doc();
|
||||
writeProject(doc, emptyProject());
|
||||
let counter = 0;
|
||||
const newId = () => `port-${++counter}`;
|
||||
addDevice(doc, { kind: 'networkSwitch', id: 'switch', name: 'Switch', x: 100, y: 100, newId }, 'me');
|
||||
for (const [id, address] of [
|
||||
['a', '192.168.0.20'],
|
||||
['b', '192.168.0.30'],
|
||||
['c', '192.168.0.31'],
|
||||
] as const) {
|
||||
addDevice(doc, { kind: 'pc', id, name: id, x: 0, y: 0, newId }, 'me');
|
||||
connect(doc, { node: id }, { node: 'switch' }, `cable-${id}`, 'me');
|
||||
setHostAddress(doc, id, 'ipAddress', address, 'me');
|
||||
setSoftware(doc, id, { install: ['gnutella'], remove: [] }, 'de', 'me');
|
||||
}
|
||||
changeFiles(doc, 'c', 'me', (files) => files.writeTextFile('/peer2peer/notizen.txt', 'Notiz'));
|
||||
let now = 0;
|
||||
const room = new SimulationRoom(doc, { now: () => now, seed: () => 3 });
|
||||
const views = new Map<string, GnutellaView | null>();
|
||||
const errors: string[] = [];
|
||||
const lines: unknown[] = [];
|
||||
const client = room.connect((message: HostMessage) => {
|
||||
if (message.kind === 'view' && message.app === 'gnutella') views.set(message.node, message.view as GnutellaView | null);
|
||||
if (message.kind === 'app' && message.app === 'gnutella') lines.push(...message.lines);
|
||||
if (message.kind === 'error') errors.push(message.code);
|
||||
});
|
||||
const send = (fields: object) => room.receive(client, { v: 1, ...fields });
|
||||
send({ kind: 'sub', topics: ['app:a/gnutella', 'app:b/gnutella', 'app:switch/gnutella'] });
|
||||
send({ kind: 'start' });
|
||||
const run = (milliseconds: number) => {
|
||||
for (let elapsed = 0; elapsed < milliseconds; elapsed += TICK_MILLISECONDS) {
|
||||
now += TICK_MILLISECONDS;
|
||||
room.tick();
|
||||
}
|
||||
};
|
||||
run(TICK_MILLISECONDS);
|
||||
const act = (node: string, action: string, args: object = {}) => send({ kind: 'input', node, app: 'gnutella', action, args, reqId: action });
|
||||
return { doc, views, errors, lines, act, run, send };
|
||||
}
|
||||
|
||||
describe('the Gnutella window in the room', () => {
|
||||
it('shows the peers, the results and the files, and takes its actions', () => {
|
||||
const { doc, views, errors, lines, act, run } = setUp();
|
||||
const view = () => views.get('a')!;
|
||||
expect(view()).toEqual({ peers: [], results: [], files: [], maximumKnownPeers: 3 });
|
||||
act('b', 'join', { address: '192.168.0.31' });
|
||||
run(3000);
|
||||
act('a', 'join', { address: '192.168.0.30' });
|
||||
run(3000);
|
||||
// The ping goes on to .31, whose pong makes it a peer too.
|
||||
expect(view().peers).toEqual(['192.168.0.30', '192.168.0.31']);
|
||||
act('a', 'search', { criteria: 'notiz' });
|
||||
run(3000);
|
||||
expect(view().results).toEqual([['192.168.0.31', 'notizen.txt', ' 5 B']]);
|
||||
act('a', 'download', { row: 0 });
|
||||
run(3000);
|
||||
expect(view().files).toEqual([['notizen.txt', 'txt']]);
|
||||
// In the document's file at once.
|
||||
expect(fileText(doc, 'a', '/peer2peer/notizen.txt')?.toString()).toBe('Notiz');
|
||||
act('a', 'clearResults');
|
||||
expect(view().results).toEqual([]);
|
||||
// "Stopp!" at once: the hits on their way are lost.
|
||||
act('a', 'search', { criteria: 'notiz' });
|
||||
act('a', 'cancelSearch');
|
||||
run(3000);
|
||||
expect(view().results).toEqual([]);
|
||||
act('a', 'setMaximumPeers', { count: 12 });
|
||||
expect(view().maximumKnownPeers).toBe(12);
|
||||
act('a', 'leave');
|
||||
expect(view().peers).toEqual([]);
|
||||
expect(errors).toEqual([]);
|
||||
// The window has no lines: none are sent.
|
||||
expect(lines).toEqual([]);
|
||||
});
|
||||
|
||||
it('leaves in the project what the run made of the number of peers', () => {
|
||||
const { doc, act, send } = setUp();
|
||||
act('a', 'setMaximumPeers', { count: 7 });
|
||||
send({ kind: 'stop' });
|
||||
expect(readProject(doc).gnutellaConfigurations.get('a')).toEqual({ maximumKnownPeers: 7 });
|
||||
expect(readProject(doc).gnutellaConfigurations.get('b')).toEqual({ maximumKnownPeers: 3 });
|
||||
});
|
||||
|
||||
it('refuses what the window never hands over', () => {
|
||||
const { views, act, errors } = setUp();
|
||||
act('a', 'join', { address: '192.168.0.300' });
|
||||
act('a', 'join', { address: 5 });
|
||||
act('a', 'search', { criteria: 3 });
|
||||
act('a', 'download', { row: 0 });
|
||||
act('a', 'download', { row: -1 });
|
||||
act('a', 'download', { row: 0.5 });
|
||||
act('a', 'setMaximumPeers', { count: -1 });
|
||||
act('a', 'setMaximumPeers', { count: '4' });
|
||||
act('a', 'setMaximumPeers', { count: 2_147_483_648 });
|
||||
act('a', 'restart');
|
||||
act('switch', 'leave');
|
||||
expect(errors).toEqual(['badArgs', 'badArgs', 'badArgs', 'badArgs', 'badArgs', 'badArgs', 'badArgs', 'badArgs', 'badArgs', 'unknownAction', 'unknownApp']);
|
||||
expect(views.get('switch')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -196,7 +196,8 @@ describe('a simulation room', () => {
|
||||
expect(reason({ node: 'nobody', app: 'terminal', action: 'enter', args: { line: 'x' } })).toMatchObject({ code: 'unknownNode' });
|
||||
expect(reason({ node: node('Switch 1').id, app: 'terminal', action: 'enter', args: { line: 'x' } })).toMatchObject({ code: 'unknownApp' });
|
||||
expect(reason({ node: notebook, app: 'terminal', action: 'paint' })).toMatchObject({ code: 'unknownAction' });
|
||||
expect(reason({ node: notebook, app: 'gnutella', action: 'search' })).toMatchObject({ code: 'unknownAction' });
|
||||
// A program the computer does not have.
|
||||
expect(reason({ node: notebook, app: 'gnutella', action: 'search' })).toMatchObject({ code: 'unknownApp' });
|
||||
expect(reason({ node: notebook, app: 'terminal', action: 'enter', args: { line: 7 } })).toMatchObject({ code: 'badArgs' });
|
||||
expect(reason({ node: notebook, app: 'terminal', action: 'interrupt' })).toEqual({ v: 1, kind: 'ack', reqId: 'r' });
|
||||
send({ kind: 'nonsense', reqId: 'q' });
|
||||
@@ -458,7 +459,7 @@ describe("a simulation room's browser and web server windows", () => {
|
||||
[server, 'webServer', 'setVirtualHost', { row: 0, column: 1, value: 2 }, 'badArgs'],
|
||||
[server, 'webServer', 'restart', {}, 'unknownAction'],
|
||||
[notebook, 'webServer', 'setActive', { active: true }, 'unknownApp'],
|
||||
[server, 'gnutella', 'join', {}, 'unknownAction'],
|
||||
[server, 'gnutella', 'join', { address: '192.168.0.1' }, 'unknownApp'],
|
||||
];
|
||||
for (const [target, app, action, args, code] of cases) {
|
||||
send({ kind: 'input', node: target, app, action, args, reqId: `${app}.${action}.${JSON.stringify(args)}` });
|
||||
|
||||
Reference in New Issue
Block a user