File size: 3,228 Bytes
4e23b01
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
import { randomBytes } from 'node:crypto';
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';

import { parse as parseToml, stringify as stringifyToml } from 'smol-toml';

import { IGuiStoreService } from './guiStore';

export interface GuiStoreLogger {
  warn(obj: unknown, msg: string): void;
}

const noopLogger: GuiStoreLogger = { warn: () => {} };

function emptyStore(): Record<string, string> {
  return Object.create(null) as Record<string, string>;
}

export class GuiStoreService implements IGuiStoreService {
  readonly _serviceBrand: undefined;

  private readonly filePath: string;
  private readonly logger: GuiStoreLogger;
  private queue: Promise<void> = Promise.resolve();

  constructor(homeDir: string, logger?: GuiStoreLogger) {
    this.filePath = join(homeDir, 'gui.toml');
    this.logger = logger ?? noopLogger;
  }

  async getItem(key: string): Promise<string | null> {
    const all = await this.readAll();
    if (!Object.prototype.hasOwnProperty.call(all, key)) return null;
    return all[key] ?? null;
  }

  async setItem(key: string, value: string): Promise<void> {
    await this.withLock(async () => {
      const all = await this.readAll();
      all[key] = value;
      await this.writeAll(all);
    });
  }

  async removeItem(key: string): Promise<void> {
    await this.withLock(async () => {
      const all = await this.readAll();
      if (Object.prototype.hasOwnProperty.call(all, key)) {
        delete all[key];
        await this.writeAll(all);
      }
    });
  }

  async clear(): Promise<void> {
    await this.withLock(() => this.writeAll(emptyStore()));
  }

  async length(): Promise<number> {
    const all = await this.readAll();
    return Object.keys(all).length;
  }

  private withLock(fn: () => Promise<void>): Promise<void> {
    const run = this.queue.then(fn);
    this.queue = run.then(
      () => undefined,
      () => undefined,
    );
    return run;
  }

  private async readAll(): Promise<Record<string, string>> {
    let text: string;
    try {
      text = await readFile(this.filePath, 'utf-8');
    } catch (error) {
      if ((error as NodeJS.ErrnoException).code === 'ENOENT') return emptyStore();
      throw error;
    }
    if (text.trim().length === 0) return emptyStore();
    try {
      const parsed = parseToml(text) as Record<string, unknown>;
      const out = emptyStore();
      for (const [k, v] of Object.entries(parsed)) {
        if (typeof v === 'string') out[k] = v;
      }
      return out;
    } catch (error) {
      this.logger.warn(
        { filePath: this.filePath, err: error },
        'gui.toml parse failed; using an empty store',
      );
      return emptyStore();
    }
  }

  private async writeAll(obj: Record<string, string>): Promise<void> {
    await mkdir(dirname(this.filePath), { recursive: true, mode: 0o700 });
    const plain: Record<string, string> = { ...obj };
    const text = Object.keys(plain).length === 0 ? '' : stringifyToml(plain);
    const tmp = `${this.filePath}.tmp.${process.pid}.${randomBytes(4).toString('hex')}`;
    await writeFile(tmp, text, { encoding: 'utf-8', mode: 0o600 });
    await rename(tmp, this.filePath);
  }
}