mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-25 18:55:19 +02:00
fix: fall back to next port when OAuth callback server can't bind 8080 (#560)
* fix: fall back to next port when OAuth callback server can't bind 8080
On Windows with Hyper-V/WSL2/Docker, port 8080 is often reserved by the
OS (EACCES) or already in use (EADDRINUSE), making sign-in completely
impossible. The app now scans 8080–8089 and binds the first available
port. For DCR providers, a stale registration locked to a blocked port
is detected and cleared so the client re-registers on the new port.
Static-client providers (Google BYOK) keep fixed-port behaviour with a
clear error message instead of a raw Node.js exception.
* fix: keep createAuthServer fixed-port by default, opt-in fallback
Address review feedback:
- Flip createAuthServer default to fixed-port; fallback is now opt-in via
{ fallback: true }. Composio (composio-handler.ts) keeps exact-port
semantics with no code change — only the Rowboat sign-in call site,
which builds its redirect URI from the actual bound port, opts in.
- Wrap post-bind setup (DCR, PKCE, auth URL) in try/catch and close the
server on any failure so the port is released for retries.
* fix: clear stale DCR registration when bound port differs from start port
This commit is contained in:
parent
c4888e2899
commit
0a3fc3736f
3 changed files with 302 additions and 167 deletions
|
|
@ -2,7 +2,8 @@ import { createServer, Server } from 'http';
|
||||||
import { URL } from 'url';
|
import { URL } from 'url';
|
||||||
|
|
||||||
const OAUTH_CALLBACK_PATH = '/oauth/callback';
|
const OAUTH_CALLBACK_PATH = '/oauth/callback';
|
||||||
const DEFAULT_PORT = 8080;
|
export const DEFAULT_PORT = 8080;
|
||||||
|
export const PORT_RANGE_SIZE = 10;
|
||||||
|
|
||||||
/** Escape HTML special characters to prevent XSS */
|
/** Escape HTML special characters to prevent XSS */
|
||||||
function escapeHtml(str: string): string {
|
function escapeHtml(str: string): string {
|
||||||
|
|
@ -19,13 +20,8 @@ export interface AuthServerResult {
|
||||||
port: number;
|
port: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function tryBindPort(
|
||||||
* Create a local HTTP server to handle OAuth callback
|
port: number,
|
||||||
* Listens on http://localhost:8080/oauth/callback
|
|
||||||
* Passes the full callback URL (including iss, scope, etc.) so openid-client validation succeeds.
|
|
||||||
*/
|
|
||||||
export function createAuthServer(
|
|
||||||
port: number = DEFAULT_PORT,
|
|
||||||
onCallback: (callbackUrl: URL) => void | Promise<void>
|
onCallback: (callbackUrl: URL) => void | Promise<void>
|
||||||
): Promise<AuthServerResult> {
|
): Promise<AuthServerResult> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
|
|
@ -96,8 +92,10 @@ export function createAuthServer(
|
||||||
});
|
});
|
||||||
|
|
||||||
server.on('error', (err: NodeJS.ErrnoException) => {
|
server.on('error', (err: NodeJS.ErrnoException) => {
|
||||||
if (err.code === 'EADDRINUSE') {
|
server.close();
|
||||||
reject(new Error(`Port ${port} is already in use`));
|
if (err.code === 'EADDRINUSE' || err.code === 'EACCES') {
|
||||||
|
// Signal caller to try next port
|
||||||
|
reject(Object.assign(new Error(err.code), { code: err.code }));
|
||||||
} else {
|
} else {
|
||||||
reject(err);
|
reject(err);
|
||||||
}
|
}
|
||||||
|
|
@ -105,3 +103,51 @@ export function createAuthServer(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a local HTTP server to handle OAuth callback.
|
||||||
|
*
|
||||||
|
* Defaults to fixed-port behaviour: only `port` is tried, and a clear error is
|
||||||
|
* thrown if it cannot be bound. This is the right behaviour for any provider
|
||||||
|
* whose redirect URI is pre-registered (Google BYOK, Composio, etc.) — those
|
||||||
|
* callers must keep using the exact port they've handed to the provider.
|
||||||
|
*
|
||||||
|
* Opt into `{ fallback: true }` only when the caller is prepared to use the
|
||||||
|
* port returned in `AuthServerResult` (i.e. the redirect URI is built from the
|
||||||
|
* actual bound port, not hard-coded). With fallback enabled, scans `port`
|
||||||
|
* through `port + PORT_RANGE_SIZE - 1` and binds the first available, handling
|
||||||
|
* both EADDRINUSE and EACCES (the latter is common on Windows when
|
||||||
|
* Hyper-V/WSL2 reserve the port).
|
||||||
|
*/
|
||||||
|
export async function createAuthServer(
|
||||||
|
port: number = DEFAULT_PORT,
|
||||||
|
onCallback: (callbackUrl: URL) => void | Promise<void>,
|
||||||
|
opts: { fallback?: boolean } = {},
|
||||||
|
): Promise<AuthServerResult> {
|
||||||
|
const fallback = opts.fallback === true;
|
||||||
|
const limit = fallback ? port + PORT_RANGE_SIZE - 1 : port;
|
||||||
|
|
||||||
|
for (let p = port; p <= limit; p++) {
|
||||||
|
try {
|
||||||
|
return await tryBindPort(p, onCallback);
|
||||||
|
} catch (err) {
|
||||||
|
const code = (err as NodeJS.ErrnoException).code;
|
||||||
|
if (fallback && (code === 'EADDRINUSE' || code === 'EACCES') && p < limit) {
|
||||||
|
console.warn(`[OAuth] Port ${p} unavailable (${code}), trying ${p + 1}…`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!fallback) {
|
||||||
|
const reason = code === 'EACCES' || code === 'EADDRINUSE'
|
||||||
|
? `Port ${port} is unavailable (${code}). This port must be free for sign-in to work — close any app using it and try again.`
|
||||||
|
: (err instanceof Error ? err.message : String(err));
|
||||||
|
throw new Error(reason);
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
`No available port found in range ${port}–${limit}. Free a port in that range and try again.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unreachable — loop always returns or throws — but satisfies TypeScript
|
||||||
|
throw new Error(`No available port found in range ${port}–${limit}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { shell } from 'electron';
|
import { shell } from 'electron';
|
||||||
import type { Server } from 'http';
|
import type { Server } from 'http';
|
||||||
import { createAuthServer } from './auth-server.js';
|
import { createAuthServer } from './auth-server.js';
|
||||||
|
import { DEFAULT_CALLBACK_PORT } from '@x/core/dist/auth/client-repo.js';
|
||||||
import * as oauthClient from '@x/core/dist/auth/oauth-client.js';
|
import * as oauthClient from '@x/core/dist/auth/oauth-client.js';
|
||||||
import type { Configuration } from '@x/core/dist/auth/oauth-client.js';
|
import type { Configuration } from '@x/core/dist/auth/oauth-client.js';
|
||||||
import { getProviderConfig, getAvailableProviders } from '@x/core/dist/auth/providers.js';
|
import { getProviderConfig, getAvailableProviders } from '@x/core/dist/auth/providers.js';
|
||||||
|
|
@ -17,7 +18,9 @@ import { isSignedIn } from '@x/core/dist/account/account.js';
|
||||||
import { getWebappUrl } from '@x/core/dist/config/remote-config.js';
|
import { getWebappUrl } from '@x/core/dist/config/remote-config.js';
|
||||||
import { claimTokensViaBackend } from '@x/core/dist/auth/google-backend-oauth.js';
|
import { claimTokensViaBackend } from '@x/core/dist/auth/google-backend-oauth.js';
|
||||||
|
|
||||||
const REDIRECT_URI = 'http://localhost:8080/oauth/callback';
|
function buildRedirectUri(port: number): string {
|
||||||
|
return `http://localhost:${port}/oauth/callback`;
|
||||||
|
}
|
||||||
|
|
||||||
/** Top-level openid-client messages that often wrap a more specific cause. */
|
/** Top-level openid-client messages that often wrap a more specific cause. */
|
||||||
const OPAQUE_OAUTH_TOP_MESSAGES = new Set(['invalid response encountered']);
|
const OPAQUE_OAUTH_TOP_MESSAGES = new Set(['invalid response encountered']);
|
||||||
|
|
@ -114,9 +117,15 @@ function getClientRegistrationRepo(): IClientRegistrationRepo {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get or create OAuth configuration for a provider
|
* Get or create OAuth configuration for a provider.
|
||||||
|
* `redirectUri` is required for DCR providers — it is the actual callback URI
|
||||||
|
* (including port) that was just bound, so the registration and auth URL stay in sync.
|
||||||
*/
|
*/
|
||||||
async function getProviderConfiguration(provider: string, credentialsOverride?: { clientId: string; clientSecret: string }): Promise<Configuration> {
|
async function getProviderConfiguration(
|
||||||
|
provider: string,
|
||||||
|
redirectUri: string = buildRedirectUri(DEFAULT_CALLBACK_PORT),
|
||||||
|
credentialsOverride?: { clientId: string; clientSecret: string },
|
||||||
|
): Promise<Configuration> {
|
||||||
const config = await getProviderConfig(provider);
|
const config = await getProviderConfig(provider);
|
||||||
const resolveClientCredentials = async (): Promise<{ clientId: string; clientSecret?: string }> => {
|
const resolveClientCredentials = async (): Promise<{ clientId: string; clientSecret?: string }> => {
|
||||||
if (config.client.mode === 'static' && config.client.clientId) {
|
if (config.client.mode === 'static' && config.client.clientId) {
|
||||||
|
|
@ -157,17 +166,20 @@ async function getProviderConfiguration(provider: string, credentialsOverride?:
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register new client
|
// Register new client with the actual redirect URI (port already bound)
|
||||||
const scopes = config.scopes || [];
|
const scopes = config.scopes || [];
|
||||||
const { config: oauthConfig, registration } = await oauthClient.registerClient(
|
const { config: oauthConfig, registration } = await oauthClient.registerClient(
|
||||||
config.discovery.issuer,
|
config.discovery.issuer,
|
||||||
[REDIRECT_URI],
|
[redirectUri],
|
||||||
scopes
|
scopes
|
||||||
);
|
);
|
||||||
|
|
||||||
// Save registration for future use
|
// Parse port from redirectUri (e.g. "http://localhost:8081/...") and save
|
||||||
await clientRepo.saveClientRegistration(provider, registration);
|
const boundPort = new URL(redirectUri).port
|
||||||
console.log(`[OAuth] ${provider}: DCR registration saved`);
|
? parseInt(new URL(redirectUri).port, 10)
|
||||||
|
: DEFAULT_CALLBACK_PORT;
|
||||||
|
await clientRepo.saveClientRegistration(provider, registration, boundPort);
|
||||||
|
console.log(`[OAuth] ${provider}: DCR registration saved (port ${boundPort})`);
|
||||||
|
|
||||||
return oauthConfig;
|
return oauthConfig;
|
||||||
}
|
}
|
||||||
|
|
@ -189,6 +201,37 @@ async function getProviderConfiguration(provider: string, credentialsOverride?:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine which port to start the OAuth callback server on for a DCR provider.
|
||||||
|
*
|
||||||
|
* If the provider has an existing registration, probes the port it was registered
|
||||||
|
* on. If that port is still available, returns it so the existing client_id keeps
|
||||||
|
* working. If it is blocked, clears the stale registration (forcing re-registration
|
||||||
|
* on the next available port) and returns DEFAULT_CALLBACK_PORT as the scan base.
|
||||||
|
*
|
||||||
|
* Exported for unit testing.
|
||||||
|
*/
|
||||||
|
export async function resolveStartPort(
|
||||||
|
provider: string,
|
||||||
|
clientRepo: IClientRegistrationRepo,
|
||||||
|
): Promise<number> {
|
||||||
|
const existingReg = await clientRepo.getClientRegistration(provider);
|
||||||
|
if (!existingReg) return DEFAULT_CALLBACK_PORT;
|
||||||
|
|
||||||
|
const registeredPort = await clientRepo.getRegisteredPort(provider);
|
||||||
|
try {
|
||||||
|
// Probe — fixed-port (no fallback) so we know whether the exact registered port is free
|
||||||
|
const probe = await createAuthServer(registeredPort, () => { /* probe */ });
|
||||||
|
probe.server.close();
|
||||||
|
console.log(`[OAuth] ${provider}: registered port ${registeredPort} still available`);
|
||||||
|
return registeredPort;
|
||||||
|
} catch {
|
||||||
|
console.log(`[OAuth] ${provider}: registered port ${registeredPort} blocked, clearing DCR registration`);
|
||||||
|
await clientRepo.clearClientRegistration(provider);
|
||||||
|
return DEFAULT_CALLBACK_PORT;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initiate OAuth flow for a provider
|
* Initiate OAuth flow for a provider
|
||||||
*/
|
*/
|
||||||
|
|
@ -225,30 +268,24 @@ export async function connectProvider(provider: string, credentials?: { clientId
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get or create OAuth configuration
|
// For static-client providers (Google BYOK) the redirect URI is pre-registered
|
||||||
const config = await getProviderConfiguration(provider, credentials);
|
// at the OAuth provider console on a fixed port — we must not scan.
|
||||||
|
// For DCR providers, resolveStartPort handles the re-registration trap.
|
||||||
|
const isStaticClient = providerConfig.client.mode === 'static';
|
||||||
|
const startPort = isStaticClient
|
||||||
|
? DEFAULT_CALLBACK_PORT
|
||||||
|
: await resolveStartPort(provider, getClientRegistrationRepo());
|
||||||
|
|
||||||
// Generate PKCE codes
|
// --- Callback server ---
|
||||||
const { verifier: codeVerifier, challenge: codeChallenge } = await oauthClient.generatePKCE();
|
// Declare `state` before the closure so the callback can close over its binding.
|
||||||
const state = oauthClient.generateState();
|
// The variable is assigned below, before shell.openExternal, so it is always
|
||||||
|
// set by the time any browser request arrives.
|
||||||
// Get scopes from config
|
let state = '';
|
||||||
const scopes = providerConfig.scopes || [];
|
|
||||||
|
|
||||||
// Store flow state
|
|
||||||
activeFlows.set(state, { codeVerifier, provider, config });
|
|
||||||
|
|
||||||
// Build authorization URL
|
|
||||||
const authUrl = oauthClient.buildAuthorizationUrl(config, {
|
|
||||||
redirect_uri: REDIRECT_URI,
|
|
||||||
scope: scopes.join(' '),
|
|
||||||
code_challenge: codeChallenge,
|
|
||||||
state,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create callback server
|
|
||||||
let callbackHandled = false;
|
let callbackHandled = false;
|
||||||
const { server } = await createAuthServer(8080, async (callbackUrl) => {
|
|
||||||
|
const { server, port: boundPort } = await createAuthServer(
|
||||||
|
startPort,
|
||||||
|
async (callbackUrl) => {
|
||||||
// Guard against duplicate callbacks (browser may send multiple requests)
|
// Guard against duplicate callbacks (browser may send multiple requests)
|
||||||
if (callbackHandled) return;
|
if (callbackHandled) return;
|
||||||
callbackHandled = true;
|
callbackHandled = true;
|
||||||
|
|
@ -349,18 +386,50 @@ export async function connectProvider(provider: string, credentials?: { clientId
|
||||||
activeFlow = null;
|
activeFlow = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
// Static providers (Google BYOK) keep fixed-port behaviour to match the
|
||||||
|
// pre-registered redirect URI at the provider's console. DCR providers
|
||||||
|
// can fall back since we register the actual bound port below.
|
||||||
|
{ fallback: !isStaticClient },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Server is bound. Any throw between here and `activeFlow = ...` would
|
||||||
|
// leak the port — `cancelActiveFlow` only closes it once activeFlow is set.
|
||||||
|
try {
|
||||||
|
// TOCTOU guard: resolveStartPort probed the registered port and found it
|
||||||
|
// free, but the port could have been grabbed between probe and real bind,
|
||||||
|
// causing fallback to a different port. The cached client_id is registered
|
||||||
|
// for the old port — clear it so getProviderConfiguration re-registers
|
||||||
|
// with the actual bound port.
|
||||||
|
if (!isStaticClient && boundPort !== startPort) {
|
||||||
|
console.log(`[OAuth] ${provider}: bound port ${boundPort} differs from start port ${startPort}, clearing stale DCR registration`);
|
||||||
|
await getClientRegistrationRepo().clearClientRegistration(provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
const redirectUri = buildRedirectUri(boundPort);
|
||||||
|
const config = await getProviderConfiguration(provider, redirectUri, credentials);
|
||||||
|
|
||||||
|
const { verifier: codeVerifier, challenge: codeChallenge } = await oauthClient.generatePKCE();
|
||||||
|
state = oauthClient.generateState();
|
||||||
|
|
||||||
|
const scopes = providerConfig.scopes || [];
|
||||||
|
activeFlows.set(state, { codeVerifier, provider, config });
|
||||||
|
|
||||||
|
const authUrl = oauthClient.buildAuthorizationUrl(config, {
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
scope: scopes.join(' '),
|
||||||
|
code_challenge: codeChallenge,
|
||||||
|
state,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set timeout to clean up abandoned flows (2 minutes)
|
// Set timeout to clean up abandoned flows (2 minutes)
|
||||||
// This prevents memory leaks if user never completes the OAuth flow
|
|
||||||
const cleanupTimeout = setTimeout(() => {
|
const cleanupTimeout = setTimeout(() => {
|
||||||
if (activeFlow?.state === state) {
|
if (activeFlow?.state === state) {
|
||||||
console.log(`[OAuth] Cleaning up abandoned OAuth flow for ${provider} (timeout)`);
|
console.log(`[OAuth] Cleaning up abandoned OAuth flow for ${provider} (timeout)`);
|
||||||
cancelActiveFlow('timed_out');
|
cancelActiveFlow('timed_out');
|
||||||
}
|
}
|
||||||
}, 2 * 60 * 1000); // 2 minutes
|
}, 2 * 60 * 1000);
|
||||||
|
|
||||||
// Store complete flow state for cleanup
|
|
||||||
activeFlow = {
|
activeFlow = {
|
||||||
provider,
|
provider,
|
||||||
state,
|
state,
|
||||||
|
|
@ -371,8 +440,16 @@ export async function connectProvider(provider: string, credentials?: { clientId
|
||||||
// Open in system browser (shares cookies/sessions with user's regular browser)
|
// Open in system browser (shares cookies/sessions with user's regular browser)
|
||||||
shell.openExternal(authUrl.toString());
|
shell.openExternal(authUrl.toString());
|
||||||
|
|
||||||
// Wait for callback (server will handle it)
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
|
} catch (setupError) {
|
||||||
|
// Post-bind setup failed — close the server so the port is released and
|
||||||
|
// a retry isn't blocked by our own zombie listener.
|
||||||
|
server.close();
|
||||||
|
if (state) {
|
||||||
|
activeFlows.delete(state);
|
||||||
|
}
|
||||||
|
throw setupError;
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('OAuth connection failed:', error);
|
console.error('OAuth connection failed:', error);
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,21 @@ import fs from 'fs/promises';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { ClientRegistrationResponse } from './types.js';
|
import { ClientRegistrationResponse } from './types.js';
|
||||||
|
|
||||||
|
export const DEFAULT_CALLBACK_PORT = 8080;
|
||||||
|
|
||||||
export interface IClientRegistrationRepo {
|
export interface IClientRegistrationRepo {
|
||||||
getClientRegistration(provider: string): Promise<ClientRegistrationResponse | null>;
|
getClientRegistration(provider: string): Promise<ClientRegistrationResponse | null>;
|
||||||
saveClientRegistration(provider: string, registration: ClientRegistrationResponse): Promise<void>;
|
/** Returns the port that was used when DCR-registering this provider, or DEFAULT_CALLBACK_PORT if not stored. */
|
||||||
|
getRegisteredPort(provider: string): Promise<number>;
|
||||||
|
saveClientRegistration(provider: string, registration: ClientRegistrationResponse, port: number): Promise<void>;
|
||||||
clearClientRegistration(provider: string): Promise<void>;
|
clearClientRegistration(provider: string): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// _registeredPort is our private field — stripped by Zod when we parse the RFC response fields
|
||||||
|
type StoredEntry = Record<string, unknown> & { _registeredPort?: number };
|
||||||
|
|
||||||
type ClientRegistrationStorage = {
|
type ClientRegistrationStorage = {
|
||||||
[provider: string]: ClientRegistrationResponse;
|
[provider: string]: StoredEntry;
|
||||||
};
|
};
|
||||||
|
|
||||||
export class FSClientRegistrationRepo implements IClientRegistrationRepo {
|
export class FSClientRegistrationRepo implements IClientRegistrationRepo {
|
||||||
|
|
@ -45,14 +52,14 @@ export class FSClientRegistrationRepo implements IClientRegistrationRepo {
|
||||||
|
|
||||||
async getClientRegistration(provider: string): Promise<ClientRegistrationResponse | null> {
|
async getClientRegistration(provider: string): Promise<ClientRegistrationResponse | null> {
|
||||||
const config = await this.readConfig();
|
const config = await this.readConfig();
|
||||||
const registration = config[provider];
|
const entry = config[provider];
|
||||||
if (!registration) {
|
if (!entry) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate registration structure
|
// Validate registration structure (Zod strips unknown fields like _registeredPort)
|
||||||
try {
|
try {
|
||||||
return ClientRegistrationResponse.parse(registration);
|
return ClientRegistrationResponse.parse(entry);
|
||||||
} catch {
|
} catch {
|
||||||
// Invalid registration, remove it
|
// Invalid registration, remove it
|
||||||
await this.clearClientRegistration(provider);
|
await this.clearClientRegistration(provider);
|
||||||
|
|
@ -60,9 +67,14 @@ export class FSClientRegistrationRepo implements IClientRegistrationRepo {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveClientRegistration(provider: string, registration: ClientRegistrationResponse): Promise<void> {
|
async getRegisteredPort(provider: string): Promise<number> {
|
||||||
const config = await this.readConfig();
|
const config = await this.readConfig();
|
||||||
config[provider] = registration;
|
return config[provider]?._registeredPort ?? DEFAULT_CALLBACK_PORT;
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveClientRegistration(provider: string, registration: ClientRegistrationResponse, port: number): Promise<void> {
|
||||||
|
const config = await this.readConfig();
|
||||||
|
config[provider] = { ...registration, _registeredPort: port };
|
||||||
await this.writeConfig(config);
|
await this.writeConfig(config);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue