diff --git a/packages/add_passwordcommand_imap.patch b/packages/add_passwordcommand_imap.patch new file mode 100644 index 0000000..0032f6a --- /dev/null +++ b/packages/add_passwordcommand_imap.patch @@ -0,0 +1,274 @@ +diff -x __pycache__ -ru thunderbird-128.10.1/comm/mailnews/base/src/nsMsgIncomingServer.cpp thunderbird/thunderbird-128.10.1/comm/mailnews/base/src/nsMsgIncomingServer.cpp +--- thunderbird-128.10.1/comm/mailnews/base/src/nsMsgIncomingServer.cpp 2025-05-13 15:22:25.000000000 +0200 ++++ thunderbird/thunderbird-128.10.1/comm/mailnews/base/src/nsMsgIncomingServer.cpp 2025-08-21 03:49:50.217872643 +0200 +@@ -41,6 +41,15 @@ + #include "mozilla/Unused.h" + #include "nsIUUIDGenerator.h" + #include "nsIArray.h" ++#include "mozilla/SpinEventLoopUntil.h" ++#include "nsIProcess.h" ++#include "mozilla/RandomNum.h" ++#include "nsIServerSocket.h" ++#include "nsIAsyncInputStream.h" ++#include "nsISocketTransport.h" ++#include "nsIBinaryInputStream.h" ++#include "nsIObjectInputStream.h" ++#include "nsDirectoryServiceDefs.h" + + #define PORT_NOT_SET -1 + +@@ -707,12 +716,241 @@ + return NS_OK; + } + ++nsresult ++nsMsgIncomingServer::GetPasswordCommand(nsTArray &passwordCommand) { ++ nsString serializedCommand; ++ ++ nsresult rv = GetUnicharValue("passwordCommand", serializedCommand); ++ NS_ENSURE_SUCCESS(rv, rv); ++ ++ if (serializedCommand.Length() == 0) ++ return NS_OK; ++ ++ // Serialization is achieved by joining the arguments with a comma. ++ // Commas are themselves allowed to be backslash-espaced. ++ ++ nsString currentArgument; ++ bool nextShouldBeEscaped = false; ++ ++ for (unsigned int i = 0; i < serializedCommand.Length(); i++) { ++ char c = serializedCommand[i]; ++ ++ switch (c) { ++ case ',': ++ if (nextShouldBeEscaped) { ++ currentArgument.Append(','); ++ nextShouldBeEscaped = false; ++ } else { ++ passwordCommand.AppendElement(currentArgument); ++ currentArgument.Truncate(); ++ } ++ break; ++ ++ case '\\': ++ if (nextShouldBeEscaped) ++ currentArgument.Append(','); ++ ++ nextShouldBeEscaped = !nextShouldBeEscaped; ++ break; ++ ++ default: ++ currentArgument.Append(c); ++ break; ++ } ++ } ++ ++ if (nextShouldBeEscaped) ++ currentArgument.Append('\\'); ++ passwordCommand.AppendElement(currentArgument); ++ ++ return NS_OK; ++} ++ ++class PasswordCommandObserver final ++ : public nsIObserver, ++ public nsIServerSocketListener, ++ public nsIInputStreamCallback { ++ public: ++ NS_DECL_ISUPPORTS ++ NS_DECL_NSIOBSERVER ++ NS_DECL_NSISERVERSOCKETLISTENER ++ NS_DECL_NSIINPUTSTREAMCALLBACK ++ ++ private: ++ bool isCommandDone; ++ bool passwordReceived; ++ nsAString& password; ++ nsTArray received; ++ ++ public: ++ PasswordCommandObserver(nsAString& password); ++ bool IsDone(); ++ private: ++ ~PasswordCommandObserver(); ++}; ++ ++NS_IMPL_ISUPPORTS(PasswordCommandObserver, nsIObserver, nsIServerSocketListener, nsIInputStreamCallback) ++ ++PasswordCommandObserver::PasswordCommandObserver(nsAString& password) : password(password) { ++ isCommandDone = false; ++ passwordReceived = false; ++} ++PasswordCommandObserver::~PasswordCommandObserver() {} ++ ++bool ++PasswordCommandObserver::IsDone() { ++ return isCommandDone && passwordReceived; ++} ++ ++NS_IMETHODIMP ++PasswordCommandObserver::Observe(nsISupports* aSubject, const char* aTopic, ++ const char16_t* aData) { ++ isCommandDone = true; ++ ++ return strcmp(aTopic, "process-failed") == 0 ? NS_ERROR_FAILURE : NS_OK; ++} ++ ++NS_IMETHODIMP ++PasswordCommandObserver::OnSocketAccepted(nsIServerSocket *aServ, nsISocketTransport *aTransport) { ++ nsresult rv; ++ ++ // The socket can be closed. This does not close the existing connection. ++ rv = aServ->Close(); ++ NS_ENSURE_SUCCESS(rv, rv); ++ ++ nsCOMPtr stream; ++ rv = aTransport->OpenInputStream(0, 0, 0, getter_AddRefs(stream)); ++ NS_ENSURE_SUCCESS(rv, rv); ++ ++ nsCOMPtr astream; ++ astream = do_QueryInterface(stream); ++ rv = astream->AsyncWait(this, 0, 0, NS_GetCurrentThread()); ++ NS_ENSURE_SUCCESS(rv, rv); ++ ++ return NS_OK; ++} ++ ++NS_IMETHODIMP ++PasswordCommandObserver::OnStopListening(nsIServerSocket *aServ, nsresult aStatus) { ++ return NS_OK; ++} ++ ++NS_IMETHODIMP ++PasswordCommandObserver::OnInputStreamReady(nsIAsyncInputStream *aStream) { ++ nsresult rv; ++ ++ int64_t len; ++ rv = aStream->Available((uint64_t*)&len); ++ if (NS_FAILED(rv)) ++ // note: aStream->Available() can also return -1. ++ len = -1; ++ ++ if (len == -1) { ++ password.Assign(NS_ConvertUTF8toUTF16((char*)received.Elements(), received.Length())); ++ passwordReceived = true; ++ } else { ++ if (len > 0) { ++ nsCOMPtr bin = NS_NewObjectInputStream(aStream); ++ nsTArray data; ++ rv = bin->ReadByteArray(len, data); ++ NS_ENSURE_SUCCESS(rv, rv); ++ received.AppendElements(data); ++ } ++ ++ rv = aStream->AsyncWait(this, 0, 0, NS_GetCurrentThread()); ++ NS_ENSURE_SUCCESS(rv, rv); ++ } ++ ++ return NS_OK; ++} ++ ++nsresult ++nsMsgIncomingServer::RunPasswordCommand(nsTArray &passwordCommand, nsAString& password) { ++ nsresult rv; ++ ++ nsCOMPtr socketFile; ++ rv = NS_GetSpecialDirectory(NS_OS_TEMP_DIR, getter_AddRefs(socketFile)); ++ NS_ENSURE_SUCCESS(rv, rv); ++ nsAutoString tempSocketName; ++ uint64_t randomSocketName = mozilla::RandomUint64().valueOrFrom([] { ++ return 0; ++ }); ++ const char* alphabet = "abcdefghijklmnopqrstuvwxyz0123456789"; ++ tempSocketName.AppendPrintf( ++ "tmp-%c%c%c.sock", ++ alphabet[randomSocketName % 36], ++ alphabet[(randomSocketName / 36) % 36], ++ alphabet[(randomSocketName / (36*36)) % 36] ++ ); ++ socketFile->AppendRelativePath(tempSocketName); ++ ++ nsCOMPtr socket = ++ do_CreateInstance(NS_SERVERSOCKET_CONTRACTID); ++ socket->InitWithFilename(socketFile, 0600, -1); ++ ++ auto executable = passwordCommand[0]; ++ nsCOMPtr executableFile = ++ do_CreateInstance("@mozilla.org/file/local;1", &rv); ++ NS_ENSURE_SUCCESS(rv, rv); ++ executableFile->InitWithPath(executable); ++ ++ nsCOMPtr process = ++ do_CreateInstance("@mozilla.org/process/util;1", &rv); ++ NS_ENSURE_SUCCESS(rv, rv); ++ rv = process->Init(executableFile); ++ NS_ENSURE_SUCCESS(rv, rv); ++ ++ auto observer = new PasswordCommandObserver(password); ++ ++ socket->AsyncListen(observer); ++ ++ auto argsCount = passwordCommand.Length(); ++ const char** args = new const char*[argsCount]; ++ for (unsigned int i = 0; i < argsCount - 1; i++) { ++ NS_ConvertUTF16toUTF8 argUtf8(passwordCommand[i + 1]); ++ args[i] = strdup(argUtf8.get()); ++ } ++ nsString socketPath; ++ rv = socketFile->GetPath(socketPath); ++ NS_ENSURE_SUCCESS(rv, rv); ++ NS_ConvertUTF16toUTF8 socketPathUtf8(socketPath); ++ args[argsCount - 1] = strdup(socketPathUtf8.get()); ++ ++ rv = process->RunAsync(args, argsCount, observer, false); ++ if (NS_FAILED(rv)) ++ return rv; ++ ++ mozilla::SpinEventLoopUntil("nsMsgIncomingServer::RunPasswordCommand"_ns, [&]() { ++ return observer->IsDone(); ++ }); ++ ++ for (unsigned int i = 0; i < argsCount; i++) ++ free((void*)args[i]); ++ delete[] args; ++ ++ return NS_OK; ++} ++ + NS_IMETHODIMP + nsMsgIncomingServer::GetPasswordWithUI(const nsAString& aPromptMessage, + const nsAString& aPromptTitle, + nsAString& aPassword) { + nsresult rv = NS_OK; + ++ nsTArray passwordCommand; ++ rv = GetPasswordCommand(passwordCommand); ++ NS_ENSURE_SUCCESS(rv, rv); ++ ++ if (passwordCommand.Length() > 0) { ++ rv = RunPasswordCommand(passwordCommand, aPassword); ++ NS_ENSURE_SUCCESS(rv, rv); ++ ++ rv = SetPassword(aPassword); ++ NS_ENSURE_SUCCESS(rv, rv); ++ ++ return NS_OK; ++ } ++ + if (m_password.IsEmpty()) { + // let's see if we have the password in the password manager and + // can avoid this prompting thing. This makes it easier to get embedders +diff -x __pycache__ -ru thunderbird-128.10.1/comm/mailnews/base/src/nsMsgIncomingServer.h thunderbird/thunderbird-128.10.1/comm/mailnews/base/src/nsMsgIncomingServer.h +--- thunderbird-128.10.1/comm/mailnews/base/src/nsMsgIncomingServer.h 2025-05-13 15:22:25.000000000 +0200 ++++ thunderbird/thunderbird-128.10.1/comm/mailnews/base/src/nsMsgIncomingServer.h 2025-08-19 12:39:19.236805257 +0200 +@@ -48,6 +48,9 @@ + nsCString m_serverKey; + bool m_hasShutDown; + ++ nsresult GetPasswordCommand(nsTArray &passwordCommand); ++ nsresult RunPasswordCommand(nsTArray &passwordCommand, nsAString& password); ++ + // Sets m_password, if password found. Can return NS_ERROR_ABORT if the + // user cancels the master password dialog. + nsresult GetPasswordWithoutUI(); diff --git a/packages/add_passwordcommand_smtp.patch b/packages/add_passwordcommand_smtp.patch new file mode 100644 index 0000000..d6da0c8 --- /dev/null +++ b/packages/add_passwordcommand_smtp.patch @@ -0,0 +1,298 @@ +--- a/comm/mailnews/base/src/MailAuthenticator.sys.mjs ++++ b/comm/mailnews/base/src/MailAuthenticator.sys.mjs +@@ -229,7 +229,7 @@ + this._server.forgetPassword(); + } + +- getPassword() { ++ async getPassword() { + if (this._server.password) { + return this._server.password; + } +@@ -261,8 +261,8 @@ + * + * @returns {string} + */ +- getByteStringPassword() { +- return MailStringUtils.stringToByteString(this.getPassword()); ++ async getByteStringPassword() { ++ return MailStringUtils.stringToByteString(await this.getPassword()); + } + + /** +@@ -270,10 +270,12 @@ + * + * @returns {string} + */ +- getPlainToken() { ++ async getPlainToken() { + // According to rfc4616#section-2, password should be UTF-8 BinaryString + // before base64 encoded. +- return btoa("\0" + this.username + "\0" + this.getByteStringPassword()); ++ return btoa( ++ "\0" + this.username + "\0" + (await this.getByteStringPassword()) ++ ); + } + + async getOAuthToken() { +--- a/comm/mailnews/compose/src/SmtpServer.sys.mjs ++++ b/comm/mailnews/compose/src/SmtpServer.sys.mjs +@@ -7,9 +7,22 @@ + + const lazy = {}; + ChromeUtils.defineESModuleGetters(lazy, { ++ FileUtils: "resource:///modules/FileUtils.sys.mjs", + SmtpClient: "resource:///modules/SmtpClient.sys.mjs", + }); + ++const UnixServerSocket = Components.Constructor( ++ "@mozilla.org/network/server-socket;1", ++ "nsIServerSocket", ++ "initWithFilename" ++); ++const currentThread = Cc["@mozilla.org/thread-manager;1"].getService().currentThread; ++const BinaryInputStream = Components.Constructor( ++ "@mozilla.org/binaryinputstream;1", ++ "nsIBinaryInputStream", ++ "setInputStream" ++); ++ + /** + * This class represents a single SMTP server. + * +@@ -318,7 +331,161 @@ + this._password = password; + } + +- getPasswordWithUI(promptMessage, promptTitle) { ++ getPasswordCommand() { ++ let serializedCommand = this._getCharPrefWithDefault("passwordCommand"); ++ if (serializedCommand === "") ++ return null; ++ ++ // Serialization is achieved by joining the arguments with a comma. ++ // Comma are themselves allowed to be backslash-espaced. ++ ++ let currentArgument = ""; ++ let nextShouldBeEscaped = false; ++ let commandArguments = []; ++ ++ for (let i = 0; i < serializedCommand.length; i++) { ++ let c = serializedCommand[i]; ++ ++ switch (c) { ++ case ',': ++ if (nextShouldBeEscaped) { ++ currentArgument += ','; ++ nextShouldBeEscaped = false; ++ } else { ++ commandArguments.push(currentArgument); ++ currentArgument = ''; ++ } ++ break; ++ ++ case '\\': ++ if (nextShouldBeEscaped) ++ currentArgument += '\\'; ++ ++ nextShouldBeEscaped = !nextShouldBeEscaped; ++ break; ++ ++ default: ++ currentArgument += c; ++ break; ++ } ++ } ++ ++ if (nextShouldBeEscaped) ++ currentArgument += '\\'; ++ commandArguments.push(currentArgument); ++ ++ return commandArguments; ++ } ++ ++ async runPasswordCommand(passwordCommand) { ++ let executable = passwordCommand[0]; ++ let args = passwordCommand.splice(1); ++ ++ let executableFile = new lazy.FileUtils.File(executable); ++ ++ function genSocketFile() { ++ let tmpDir = lazy.FileUtils.getDir("TmpD", [], false); ++ let random = Math.round(Math.random() * 36 ** 3).toString(36); ++ return new lazy.FileUtils.File( ++ PathUtils.join(tmpDir.path, `tmp-${random}.sock`) ++ ); ++ } ++ let socketFile = genSocketFile(); ++ ++ let socket = new UnixServerSocket( ++ socketFile, ++ parseInt("600", 8), ++ -1 ++ ); ++ ++ var process = Cc['@mozilla.org/process/util;1'].createInstance(Ci.nsIProcess); ++ process.init(executableFile); ++ let processPromise = new Promise((resolve, reject) => { ++ process.runAsync( ++ args.concat(socketFile.path), ++ args.length + 1, ++ { ++ observe(subject, topic, data) { ++ switch (topic) { ++ case "process-finished": ++ resolve(); ++ break; ++ ++ case "process-failed": ++ reject(); ++ break; ++ } ++ } ++ }, ++ false ++ ); ++ }); ++ ++ let passwordPromise = new Promise((resolve, reject) => { ++ socket.asyncListen({ ++ "onSocketAccepted": (server, transport) => { ++ // The socket can be closed. This does not close the existing connection. ++ socket.close(); ++ ++ var stream = transport.openInputStream(0, 0, 0); ++ ++ let received = []; ++ let observer = { ++ onInputStreamReady(stream) { ++ let len = 0; ++ try { ++ len = stream.available(); ++ } catch (e) { ++ // note: stream.available() can also return -1. ++ len = -1; ++ } ++ ++ if (len == -1) { ++ let decoder = new TextDecoder(); ++ let result = decoder.decode(new Uint8Array(received)); ++ ++ resolve(result); ++ } else { ++ if (len > 0) { ++ let bin = new BinaryInputStream(stream); ++ let data = Array.from(bin.readByteArray(len)); ++ received = received.concat(data); ++ } ++ ++ stream.asyncWait(observer, 0, 0, currentThread); ++ } ++ } ++ }; ++ stream.asyncWait(observer, 0, 0, currentThread); ++ } ++ }); ++ }); ++ ++ let [_, result] = await Promise.allSettled([ ++ processPromise, ++ passwordPromise, ++ ]); ++ ++ socketFile.remove(false); ++ ++ if (result.status == "fulfilled") ++ return result.value; ++ else ++ return Promise.reject(result.reason); ++ } ++ ++ async getPasswordWithUI(promptMessage, promptTitle) { ++ let passwordCommand = this.getPasswordCommand(); ++ if (passwordCommand !== null) { ++ let password = await this.runPasswordCommand(passwordCommand); ++ ++ if (password === null) ++ throw Components.Exception("Password command failure", Cr.NS_ERROR_ABORT); ++ ++ this.password = password; ++ return this.password; ++ } ++ + // This prompt has a checkbox for saving password. + const authPrompt = Cc["@mozilla.org/messenger/msgAuthPrompt;1"].getService( + Ci.nsIAuthPrompt +--- a/comm/mailnews/compose/src/SmtpClient.sys.mjs ++++ b/comm/mailnews/compose/src/SmtpClient.sys.mjs +@@ -362,7 +362,7 @@ + * + * @param {string} chunk Chunk of data received from the server + */ +- _parse(chunk) { ++ async _parse(chunk) { + // Lines should always end with but you never know, might be only as well + var lines = (this._parseRemainder + (chunk || "")).split(/\r?\n/); + this._parseRemainder = lines.pop(); // not sure if the line has completely arrived yet +@@ -399,14 +399,14 @@ + success: statusCode >= 200 && statusCode < 300, + }; + +- this._onCommand(response); ++ await this._onCommand(response); + this._parseBlock = { + data: [], + statusCode: null, + }; + } + } else { +- this._onCommand({ ++ await this._onCommand({ + success: false, + statusCode: this._parseBlock.statusCode || null, + data: [lines[i]].join("\n"), +@@ -456,7 +456,7 @@ + // rejects AUTH PLAIN then closes the connection, the client then sends AUTH + // LOGIN. This line guarantees onclose is called before sending AUTH LOGIN. + await new Promise(resolve => setTimeout(resolve)); +- this._parse(stringPayload); ++ await this._parse(stringPayload); + }; + + /** +@@ -732,7 +732,7 @@ + this.logger.debug("Authentication via AUTH PLAIN"); + this._currentAction = this._actionAUTHComplete; + this._sendCommand( +- "AUTH PLAIN " + this._authenticator.getPlainToken(), ++ "AUTH PLAIN " + (await this._authenticator.getPlainToken()), + true + ); + return; +@@ -1065,7 +1065,7 @@ + * @param {{statusCode: number, data: string}} command - Parsed command from + * the server. + */ +- _actionAUTH_LOGIN_PASS(command) { ++ async _actionAUTH_LOGIN_PASS(command) { + if ( + command.statusCode !== 334 || + (command.data !== btoa("Password:") && command.data !== btoa("password:")) +@@ -1075,7 +1075,7 @@ + } + this.logger.debug("AUTH LOGIN PASS"); + this._currentAction = this._actionAUTHComplete; +- let password = this._getPassword(); ++ let password = await this._getPassword(); + if ( + !Services.prefs.getBoolPref( + "mail.smtp_login_pop3_user_pass_auth_is_latin1", +@@ -1104,7 +1104,7 @@ + } + this._currentAction = this._actionAUTHComplete; + this._sendCommand( +- this._authenticator.getCramMd5Token(this._getPassword(), command.data), ++ this._authenticator.getCramMd5Token(await this._getPassword(), command.data), + true + ); + } diff --git a/profiles/basic/default.nix b/profiles/basic/default.nix index 53b5b8d..2854b67 100644 --- a/profiles/basic/default.nix +++ b/profiles/basic/default.nix @@ -13,6 +13,7 @@ ./locale.nix ./ssh.nix ./email.nix + ./git.nix ]; nix.settings.experimental-features = [ diff --git a/profiles/basic/email.nix b/profiles/basic/email.nix index b57da4f..c53bce8 100644 --- a/profiles/basic/email.nix +++ b/profiles/basic/email.nix @@ -12,7 +12,7 @@ }; smtp = { host = "smtp.hadoly.fr"; - port = 443; + port = 465; tls.enable = true; }; primary = true; @@ -34,7 +34,7 @@ }; smtp = { host = "mail.mailo.com"; - port = 443; + port = 465; tls.enable = true; }; userName = "samyavrillon@netcourrier.com"; diff --git a/profiles/basic/git.nix b/profiles/basic/git.nix new file mode 100644 index 0000000..9d29731 --- /dev/null +++ b/profiles/basic/git.nix @@ -0,0 +1,15 @@ +{config, lib, pkgs, ...}: { + +home-manager.users.mysaa.programs.git = { + enable = true; + userEmail = "mysaa@hadoly.fr"; + userName = "Mysaa Java"; + + signing.key = "880352F5FD83DF24F41413CC4E77725580DA73CE"; + + aliases = { + l = "log --oneline --graph"; + }; +}; + +} diff --git a/profiles/basic/ssh.nix b/profiles/basic/ssh.nix index 5033037..0cd4784 100644 --- a/profiles/basic/ssh.nix +++ b/profiles/basic/ssh.nix @@ -1,12 +1,16 @@ {config, lib, pkgs, ...}: { programs.ssh = { - extraConfig = " + extraConfig = '' Host git-ssh.hadoly.fr Hostname git-ssh.hadoly.fr IdentityFile /run/secrets/ssh/mysaa@git.hadoly.fr Port 6900 - "; + + Host github.com + HostName github.com + IdentityFile /run/secrets/ssh/MysaaJava@github.com + ''; }; } diff --git a/profiles/graphical/packages.nix b/profiles/graphical/packages.nix index 35d4b7e..790ba77 100644 --- a/profiles/graphical/packages.nix +++ b/profiles/graphical/packages.nix @@ -10,6 +10,10 @@ discord ]; + environment.plasma6.excludePackages = with pkgs.kdePackages; [ + elisa + ]; + home-manager.users.mysaa.home.packages = with pkgs; [ firefox vlc @@ -23,14 +27,57 @@ edwinkofler.vscode-hyperupcall-pack-java ]; }) + keepassxc + libreoffice + #hyphenDicts.fr_FR + hyphenDicts.en_US + hyphenDicts.de_DE + steam ]; + home-manager.users.mysaa.services.kdeconnect.enable = true; + # We open kdeconnect ports + networking.firewall = rec { + allowedTCPPortRanges = [ { from = 1714; to = 1764; } ]; + allowedUDPPortRanges = allowedTCPPortRanges; + }; + home-manager.users.mysaa.accounts.email.accounts."hadoly".thunderbird.enable = true; home-manager.users.mysaa.accounts.email.accounts."personal".thunderbird.enable = true; home-manager.users.mysaa.programs.thunderbird = { enable = true; profiles.default = { isDefault = true; + settings = {} + // lib.attrsets.concatMapAttrs ( + _: account: + lib.optionalAttrs (account.passwordCommand != null) ( + let + id = builtins.hashString "sha256" account.name; + command = lib.concatStringsSep " " account.passwordCommand; + passwordScript = pkgs.writeShellScript "get-password.sh" '' + ${command} | tr -d $'\n' | ${pkgs.netcat}/bin/nc -w 0 -U $1 + ''; + in + lib.optionalAttrs (account.smtp != null) { + "mail.smtpserver.smtp_${id}.passwordCommand" = toString passwordScript; + } + // lib.optionalAttrs (account.imap != null) { + "mail.server.server_${id}.passwordCommand" = toString passwordScript; + } + ) + ) config.home-manager.users.mysaa.accounts.email.accounts; }; }; + + home-manager.users.mysaa.nixpkgs.overlays = [ + (final: prev: { + thunderbird-unwrapped = prev.thunderbird-unwrapped.overrideAttrs { + patches = (prev.thunderbird-unwrapped.patches or [ ]) ++ [ + ../../packages/add_passwordcommand_smtp.patch + ../../packages/add_passwordcommand_imap.patch + ]; + }; + }) + ]; }