@kirill.konshin/utils 0.0.8 → 0.0.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.ctirc +10 -0
- package/.storybook/main.ts +21 -0
- package/.storybook/preview.ts +17 -0
- package/.turbo/turbo-build.log +67 -0
- package/.turbo/turbo-test.log +118 -0
- package/CHANGELOG.md +12 -0
- package/README.md +59 -0
- package/builders/.swcrc +18 -0
- package/builders/build.config.ts +11 -0
- package/builders/bun.build.js +23 -0
- package/builders/package.json +209 -0
- package/builders/perf.mjs +63 -0
- package/builders/rollup.config.mjs +50 -0
- package/builders/rslib.config.ts +26 -0
- package/builders/tsconfig.json +21 -0
- package/builders/tsup.config.ts +20 -0
- package/builders/turbo.json +24 -0
- package/builders/vite.config.ts +37 -0
- package/demo/cache-demo.ts +2 -1
- package/package.json +166 -22
- package/src/bootstrap/adaptiveContainer.tsx +12 -0
- package/src/bootstrap/controls.stories.tsx +62 -0
- package/src/bootstrap/controls.tsx +119 -0
- package/src/bootstrap/error.stories.tsx +34 -0
- package/src/bootstrap/error.tsx +40 -0
- package/src/bootstrap/field.stories.tsx +42 -0
- package/src/bootstrap/field.tsx +35 -0
- package/src/bootstrap/footer.tsx +49 -0
- package/src/bootstrap/globalLoading.stories.tsx +25 -0
- package/src/bootstrap/globalLoading.tsx +17 -0
- package/src/bootstrap/index.ts +12 -0
- package/src/bootstrap/loading.stories.tsx +35 -0
- package/src/bootstrap/loading.tsx +22 -0
- package/src/bootstrap/main.scss +86 -0
- package/src/bootstrap/responsiveHelper.tsx +50 -0
- package/src/bootstrap/screen.stories.tsx +160 -0
- package/src/bootstrap/screen.tsx +144 -0
- package/src/bootstrap/toaster.stories.tsx +32 -0
- package/src/bootstrap/toaster.tsx +38 -0
- package/src/bootstrap/useModal.stories.tsx +51 -0
- package/src/bootstrap/useModal.tsx +88 -0
- package/src/bootstrap/useWrappedForm.ts +40 -0
- package/src/core/cache.test.ts +121 -0
- package/src/electron/README.md +13 -0
- package/src/electron/createWindow.ts +234 -0
- package/src/electron/index.ts +2 -0
- package/src/electron/updater.ts +54 -0
- package/src/electron-builder/builder.ts +150 -0
- package/src/electron-builder/images.ts +33 -0
- package/src/electron-builder/index.ts +2 -0
- package/src/mui/README.md +3 -0
- package/src/mui/formControlFieldset.stories.tsx +71 -0
- package/src/mui/formControlFieldset.tsx +26 -0
- package/src/mui/formLabelLegend.tsx +11 -0
- package/src/mui/genericControl.tsx +20 -0
- package/src/mui/index.ts +4 -0
- package/src/mui/readOnly.tsx +6 -0
- package/src/next/appLink.tsx +39 -0
- package/src/next/index.ts +5 -0
- package/src/next/measure.ts +7 -0
- package/src/next/noSSR.tsx +17 -0
- package/src/next/redirect.tsx +13 -0
- package/src/next/useIsInner.ts +13 -0
- package/src/react/apiCall.ts +25 -0
- package/src/react/form/client.tsx +62 -0
- package/src/react/form/form.tsx +109 -0
- package/src/react/form/index.ts +2 -0
- package/src/react/index.ts +4 -0
- package/src/react/useFetch.ts +29 -0
- package/src/react/useFetcher.ts +49 -0
- package/src/react-native/index.ts +3 -0
- package/src/react-native/share.ts +31 -0
- package/src/react-native/update.tsx +40 -0
- package/src/react-native/useAppState.ts +18 -0
- package/src/tailwind/fullpage.tsx +11 -0
- package/src/tailwind/index.ts +2 -0
- package/src/tailwind/responsiveHelper.tsx +16 -0
- package/src-todo/auth0.tsx +177 -0
- package/tsconfig.json +15 -18
- package/turbo.json +2 -11
- package/vite.config.ts +47 -0
- package/vite.exports.ts +121 -0
- package/build/cache.d.ts +0 -120
- package/build/cache.js +0 -191
- package/build/errors.d.ts +0 -1
- package/build/errors.js +0 -14
- package/build/index.d.ts +0 -5
- package/build/index.js +0 -5
- package/build/measure.d.ts +0 -34
- package/build/measure.js +0 -48
- package/build/mutex.d.ts +0 -5
- package/build/mutex.js +0 -23
- package/build/worker.d.ts +0 -83
- package/build/worker.js +0 -250
- package/tsconfig.tsbuildinfo +0 -1
- package/src/{cache.ts → core/cache.ts} +0 -0
- package/src/{errors.ts → core/errors.ts} +0 -0
- package/src/{index.ts → core/index.ts} +1 -1
- /package/src/{measure.ts → core/measure.ts} +0 -0
- /package/src/{mutex.ts → core/mutex.ts} +0 -0
- /package/src/{worker.ts → core/worker.ts} +0 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
2
|
+
import { fn } from '@storybook/test';
|
|
3
|
+
|
|
4
|
+
import { Toaster } from './toaster';
|
|
5
|
+
|
|
6
|
+
// More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export
|
|
7
|
+
const meta = {
|
|
8
|
+
title: 'Bootstrap / Toaster',
|
|
9
|
+
component: Toaster,
|
|
10
|
+
parameters: {
|
|
11
|
+
layout: 'centered',
|
|
12
|
+
},
|
|
13
|
+
tags: ['autodocs'],
|
|
14
|
+
argTypes: {},
|
|
15
|
+
args: { onClick: fn() },
|
|
16
|
+
} satisfies Meta<typeof Toaster>;
|
|
17
|
+
|
|
18
|
+
export default meta;
|
|
19
|
+
|
|
20
|
+
type Story = StoryObj<typeof meta>;
|
|
21
|
+
|
|
22
|
+
export const Default: Story = {
|
|
23
|
+
args: {
|
|
24
|
+
toast: 'Hello, World!',
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const Object: Story = {
|
|
29
|
+
args: {
|
|
30
|
+
toast: { message: 'Toast', variant: 'dark' },
|
|
31
|
+
},
|
|
32
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useState } from 'react';
|
|
4
|
+
import { Toast, ToastProps } from 'react-bootstrap';
|
|
5
|
+
// import { useToaster } from '../redux/toasterSlice';
|
|
6
|
+
|
|
7
|
+
export function Toaster({
|
|
8
|
+
toast,
|
|
9
|
+
}: { toast: string | { message: string; title?: string; variant?: 'dark' | 'success' | 'danger' } } & ToastProps) {
|
|
10
|
+
//const { toast, setToast } = useToaster();
|
|
11
|
+
const [dismissed, setDismissed] = useState(false);
|
|
12
|
+
|
|
13
|
+
const msg = typeof toast === 'string' ? toast : toast?.message;
|
|
14
|
+
const title = typeof toast === 'string' ? 'Notification' : toast?.title;
|
|
15
|
+
const variant = typeof toast === 'string' ? '' : toast?.variant;
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<Toast
|
|
19
|
+
onClose={() => setDismissed(true)}
|
|
20
|
+
show={!dismissed}
|
|
21
|
+
delay={10000}
|
|
22
|
+
autohide
|
|
23
|
+
bg={variant?.toLowerCase()}
|
|
24
|
+
className="position-fixed top-50 right-50 min-w-200px z-3"
|
|
25
|
+
>
|
|
26
|
+
<Toast.Header>
|
|
27
|
+
<strong className="me-auto">Notification</strong>
|
|
28
|
+
</Toast.Header>
|
|
29
|
+
<Toast.Body
|
|
30
|
+
className={
|
|
31
|
+
['dark', 'success', 'danger'].includes((variant as string)?.toLowerCase()) ? 'text-white' : ''
|
|
32
|
+
}
|
|
33
|
+
>
|
|
34
|
+
{msg}
|
|
35
|
+
</Toast.Body>
|
|
36
|
+
</Toast>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import React, { FC } from 'react';
|
|
2
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
3
|
+
import { useModal, UseModalProps } from './useModal';
|
|
4
|
+
import { Button, ButtonGroup } from 'react-bootstrap';
|
|
5
|
+
|
|
6
|
+
// More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export
|
|
7
|
+
const meta = {
|
|
8
|
+
title: 'Bootstrap / useModal',
|
|
9
|
+
parameters: {
|
|
10
|
+
layout: 'centered',
|
|
11
|
+
},
|
|
12
|
+
tags: ['autodocs'],
|
|
13
|
+
argTypes: {
|
|
14
|
+
children: { control: 'text' },
|
|
15
|
+
title: { control: 'text' },
|
|
16
|
+
cancelButton: { control: 'text' },
|
|
17
|
+
submitButton: { control: 'text' },
|
|
18
|
+
offcanvas: { control: 'check' },
|
|
19
|
+
},
|
|
20
|
+
args: {
|
|
21
|
+
children: 'Modal Content',
|
|
22
|
+
title: 'Modal Title',
|
|
23
|
+
},
|
|
24
|
+
render: function Demo(args) {
|
|
25
|
+
const { open, show, close, ModalDialog } = useModal();
|
|
26
|
+
return (
|
|
27
|
+
<>
|
|
28
|
+
<Button onClick={open}>Open Modal</Button>
|
|
29
|
+
<ModalDialog {...args} show={show}>
|
|
30
|
+
{args.children}
|
|
31
|
+
<Button onClick={close}>Close Modal</Button>
|
|
32
|
+
</ModalDialog>
|
|
33
|
+
</>
|
|
34
|
+
);
|
|
35
|
+
},
|
|
36
|
+
// args: { onSubmit: (e) => e.preventDefault() },
|
|
37
|
+
} satisfies Meta<FC<UseModalProps>>;
|
|
38
|
+
|
|
39
|
+
export default meta;
|
|
40
|
+
|
|
41
|
+
type Story = StoryObj<typeof meta>;
|
|
42
|
+
|
|
43
|
+
export const Default: Story = {
|
|
44
|
+
args: {},
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export const Offcanvas: Story = {
|
|
48
|
+
args: {
|
|
49
|
+
offcanvas: true,
|
|
50
|
+
},
|
|
51
|
+
};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import React, { useCallback, useMemo, useState } from 'react';
|
|
2
|
+
import { Button, Modal, ModalProps, Offcanvas, OffcanvasProps, Stack } from 'react-bootstrap';
|
|
3
|
+
|
|
4
|
+
export type UseModalArgs = { onClose?: any; showOnMount?: boolean };
|
|
5
|
+
|
|
6
|
+
export type UseModalProps = {
|
|
7
|
+
children?: any;
|
|
8
|
+
show?: boolean;
|
|
9
|
+
onSubmit?: (e: any) => void;
|
|
10
|
+
submitButton?: string | any;
|
|
11
|
+
cancelButton?: string | any;
|
|
12
|
+
offcanvas?: boolean;
|
|
13
|
+
title?: string | any;
|
|
14
|
+
containerProps?: ModalProps | OffcanvasProps;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function useModal({ onClose, showOnMount }: UseModalArgs = {}) {
|
|
18
|
+
const [show, setShow] = useState(showOnMount);
|
|
19
|
+
|
|
20
|
+
const close = useCallback(() => {
|
|
21
|
+
setShow(false);
|
|
22
|
+
onClose?.();
|
|
23
|
+
}, [onClose]);
|
|
24
|
+
|
|
25
|
+
const open = useCallback(() => setShow(true), []);
|
|
26
|
+
|
|
27
|
+
const defaultOnSubmit = useCallback(
|
|
28
|
+
(e) => {
|
|
29
|
+
e.preventDefault();
|
|
30
|
+
close();
|
|
31
|
+
},
|
|
32
|
+
[close],
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
const ModalDialog = useMemo(
|
|
36
|
+
() =>
|
|
37
|
+
function ModalDialog({
|
|
38
|
+
children,
|
|
39
|
+
show,
|
|
40
|
+
onSubmit = defaultOnSubmit,
|
|
41
|
+
submitButton = 'Done',
|
|
42
|
+
cancelButton = 'Cancel',
|
|
43
|
+
offcanvas = false,
|
|
44
|
+
title = null as any,
|
|
45
|
+
containerProps = {},
|
|
46
|
+
}: ModalProps) {
|
|
47
|
+
const Container = offcanvas ? Offcanvas : Modal;
|
|
48
|
+
const containerDefaultProps = offcanvas ? { style: { maxWidth: '85%' } } : {};
|
|
49
|
+
const Body = offcanvas ? Offcanvas.Body : Modal.Body;
|
|
50
|
+
const Header = offcanvas ? Offcanvas.Header : Modal.Header;
|
|
51
|
+
const Title = offcanvas ? Offcanvas.Title : Modal.Title;
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<Container {...{ ...containerDefaultProps, ...containerProps }} show={show} onHide={close}>
|
|
55
|
+
<form onSubmit={onSubmit}>
|
|
56
|
+
{title && (
|
|
57
|
+
<Header closeButton>
|
|
58
|
+
<Title>{title}</Title>
|
|
59
|
+
</Header>
|
|
60
|
+
)}
|
|
61
|
+
|
|
62
|
+
<Body as={Stack} gap={3}>
|
|
63
|
+
{show && children}
|
|
64
|
+
</Body>
|
|
65
|
+
|
|
66
|
+
{!offcanvas && (submitButton || cancelButton) && (
|
|
67
|
+
<Modal.Footer>
|
|
68
|
+
{cancelButton && (
|
|
69
|
+
<Button variant="outline-secondary" onClick={close}>
|
|
70
|
+
{cancelButton}
|
|
71
|
+
</Button>
|
|
72
|
+
)}
|
|
73
|
+
{submitButton && (
|
|
74
|
+
<Button variant="secondary" type="submit">
|
|
75
|
+
{submitButton}
|
|
76
|
+
</Button>
|
|
77
|
+
)}
|
|
78
|
+
</Modal.Footer>
|
|
79
|
+
)}
|
|
80
|
+
</form>
|
|
81
|
+
</Container>
|
|
82
|
+
);
|
|
83
|
+
},
|
|
84
|
+
[close, defaultOnSubmit],
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
return { show, close, open, ModalDialog };
|
|
88
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { useForm, FormState } from 'react-hook-form';
|
|
2
|
+
import { useCallback } from 'react';
|
|
3
|
+
|
|
4
|
+
export const isFormLoading = (formState: FormState<any>) => formState.isLoading || formState.isSubmitting;
|
|
5
|
+
|
|
6
|
+
export const useWrappedForm: typeof useForm = (options) => {
|
|
7
|
+
const form = useForm(options);
|
|
8
|
+
|
|
9
|
+
const handleSubmit: typeof form.handleSubmit = useCallback(
|
|
10
|
+
(onSubmit, onError) => {
|
|
11
|
+
const wrappedSubmit: typeof onSubmit = async (data) => {
|
|
12
|
+
try {
|
|
13
|
+
await onSubmit(data);
|
|
14
|
+
} catch (e) {
|
|
15
|
+
form.setError('root', { type: 'server', message: e.message });
|
|
16
|
+
console.error('Error submitting form', e);
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
return form.handleSubmit(wrappedSubmit, onError);
|
|
20
|
+
},
|
|
21
|
+
[form],
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
return { ...form, handleSubmit };
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Issue #3: Submit event still propagates to parent when using portal
|
|
29
|
+
* https://reactjs.org/docs/portals.html#event-bubbling-through-portals
|
|
30
|
+
* Event bubbling goes through React DOM instead of HTML DOM
|
|
31
|
+
* Portals don't have an effect on this one, we need to stop event propagation
|
|
32
|
+
* This should be our default form handling method
|
|
33
|
+
* https://codesandbox.io/s/react-hook-form-nested-portal-6x3fvy?file=/src/App.tsx:1905-2261
|
|
34
|
+
* https://github.com/react-hook-form/react-hook-form/issues/1005#issuecomment-988380981
|
|
35
|
+
*/
|
|
36
|
+
export const stopPropagation = (callback) => (e) => {
|
|
37
|
+
e.stopPropagation();
|
|
38
|
+
e.preventDefault();
|
|
39
|
+
callback(e);
|
|
40
|
+
};
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { expect, describe, test, vi } from 'vitest';
|
|
2
|
+
import { ANY, MaybePromise, memo, TransformerMap } from './cache';
|
|
3
|
+
|
|
4
|
+
describe('memo', async () => {
|
|
5
|
+
test('simple', async () => {
|
|
6
|
+
const random = vi.fn(() => Math.random());
|
|
7
|
+
const memoized = memo(random);
|
|
8
|
+
|
|
9
|
+
const first = await memoized();
|
|
10
|
+
const second = await memoized();
|
|
11
|
+
|
|
12
|
+
expect(first.hit).toBeFalsy();
|
|
13
|
+
expect(second.hit).toBeTruthy();
|
|
14
|
+
expect(first.value).toBe(second.value);
|
|
15
|
+
expect(memoized.size()).toBe(1);
|
|
16
|
+
|
|
17
|
+
memoized.clear();
|
|
18
|
+
expect(memoized.size()).toBe(0);
|
|
19
|
+
|
|
20
|
+
const third = await memoized();
|
|
21
|
+
expect(third.hit).toBeFalsy();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('with key', async () => {
|
|
25
|
+
const random = vi.fn((arg) => arg + Math.random().toString());
|
|
26
|
+
const memoized = memo(random);
|
|
27
|
+
|
|
28
|
+
const first = await memoized(1);
|
|
29
|
+
const second = await memoized(2);
|
|
30
|
+
|
|
31
|
+
expect(first.hit).toBeFalsy();
|
|
32
|
+
expect(second.hit).toBeFalsy();
|
|
33
|
+
expect(first.value).not.toBe(second.value);
|
|
34
|
+
expect(memoized.size()).toBe(2);
|
|
35
|
+
|
|
36
|
+
const first2 = await memoized(1);
|
|
37
|
+
const second2 = await memoized(2);
|
|
38
|
+
|
|
39
|
+
expect(first2.hit).toBeTruthy();
|
|
40
|
+
expect(first.value).toBe(first2.value);
|
|
41
|
+
expect(second2.hit).toBeTruthy();
|
|
42
|
+
expect(second.value).toBe(second2.value);
|
|
43
|
+
|
|
44
|
+
memoized.clear(1);
|
|
45
|
+
expect(memoized.size()).toBe(1);
|
|
46
|
+
memoized.clear(ANY);
|
|
47
|
+
expect(memoized.size()).toBe(0);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe('TransformerMap', async () => {
|
|
52
|
+
test('simple', async () => {
|
|
53
|
+
const dispose = vi.fn();
|
|
54
|
+
|
|
55
|
+
class Cache extends TransformerMap<number, any> {
|
|
56
|
+
write(key, value, oldValue) {
|
|
57
|
+
value.cached = true;
|
|
58
|
+
value.read = 0;
|
|
59
|
+
return value;
|
|
60
|
+
}
|
|
61
|
+
read(value, key) {
|
|
62
|
+
value.read++;
|
|
63
|
+
return value;
|
|
64
|
+
}
|
|
65
|
+
dispose(value, key) {
|
|
66
|
+
dispose(value, key);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const cache = new Cache('test');
|
|
71
|
+
|
|
72
|
+
const obj = { a: 1 };
|
|
73
|
+
|
|
74
|
+
const x1 = await cache.memo(1, obj);
|
|
75
|
+
expect(x1).toStrictEqual({ a: 1, cached: true, read: 0 });
|
|
76
|
+
|
|
77
|
+
const x2 = await cache.memo(1, obj);
|
|
78
|
+
expect(x2).toStrictEqual({ a: 1, cached: true, read: 1 });
|
|
79
|
+
|
|
80
|
+
const x3 = await cache.memo(1, { a: 2 });
|
|
81
|
+
expect(x3).toStrictEqual({ a: 2, cached: true, read: 0 });
|
|
82
|
+
expect(cache.size).toEqual(1);
|
|
83
|
+
expect(dispose).toBeCalledTimes(1);
|
|
84
|
+
expect(dispose).toBeCalledWith({ a: 1, cached: true, read: 1 }, 1);
|
|
85
|
+
|
|
86
|
+
const x4 = await cache.memo(2, { a: 20 });
|
|
87
|
+
|
|
88
|
+
expect(x4).toStrictEqual({ a: 20, cached: true, read: 0 });
|
|
89
|
+
expect(cache.size).toEqual(2);
|
|
90
|
+
expect(dispose).toBeCalledTimes(1);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('key transform', async () => {
|
|
94
|
+
const dispose = vi.fn();
|
|
95
|
+
|
|
96
|
+
class Cache extends TransformerMap<number, any> {
|
|
97
|
+
write(key, value, oldValue) {
|
|
98
|
+
return { key, read: 0 };
|
|
99
|
+
}
|
|
100
|
+
read(value, key) {
|
|
101
|
+
value.read++;
|
|
102
|
+
return value;
|
|
103
|
+
}
|
|
104
|
+
dispose(value, key) {
|
|
105
|
+
dispose(value, key);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const cache = new Cache('test');
|
|
110
|
+
|
|
111
|
+
const x1 = await cache.memo(1);
|
|
112
|
+
expect(x1).toStrictEqual({ key: 1, read: 0 });
|
|
113
|
+
|
|
114
|
+
const x2 = await cache.memo(1);
|
|
115
|
+
expect(x2).toStrictEqual({ key: 1, read: 1 });
|
|
116
|
+
|
|
117
|
+
cache.clear();
|
|
118
|
+
expect(dispose).toBeCalledTimes(1);
|
|
119
|
+
expect(dispose).toBeCalledWith({ key: 1, read: 1 }, 1);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import https from 'node:https';
|
|
3
|
+
import http from 'node:http';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import fsp from 'node:fs/promises';
|
|
6
|
+
import url from 'node:url';
|
|
7
|
+
import { Menu, app, BrowserWindow, shell, protocol, Rectangle } from 'electron';
|
|
8
|
+
import defaultMenu from 'electron-default-menu';
|
|
9
|
+
import Store from 'electron-store';
|
|
10
|
+
import { checkForUpdates } from './updater.js';
|
|
11
|
+
|
|
12
|
+
export const isDev = process.env['NODE_ENV'] === 'development';
|
|
13
|
+
export const appPath = app.getAppPath();
|
|
14
|
+
|
|
15
|
+
// See https://cs.chromium.org/chromium/src/net/base/net_error_list.h
|
|
16
|
+
const FILE_NOT_FOUND = -6;
|
|
17
|
+
|
|
18
|
+
const getPath = async (path_, file = '') => {
|
|
19
|
+
try {
|
|
20
|
+
const result = await fsp.stat(path_);
|
|
21
|
+
|
|
22
|
+
if (result.isFile()) return path_;
|
|
23
|
+
|
|
24
|
+
if (result.isDirectory()) return getPath(path.join(path_, `${file}.html`));
|
|
25
|
+
} catch {
|
|
26
|
+
/* empty */
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export function createWindow({
|
|
31
|
+
width = 1000,
|
|
32
|
+
height = 1000,
|
|
33
|
+
disableSecurity = true,
|
|
34
|
+
disableSecurityWarnings = true,
|
|
35
|
+
quitOnClose = true,
|
|
36
|
+
onOpen,
|
|
37
|
+
onClose,
|
|
38
|
+
localhostUrl = 'http://localhost:3000',
|
|
39
|
+
productionUrl = '', // in this mode Electron acts as a browser for remote app
|
|
40
|
+
webPath = path.resolve(appPath, 'web'), // must conform to what is in electron-builder.js files section
|
|
41
|
+
useStaticInDev = false,
|
|
42
|
+
updater = false,
|
|
43
|
+
rememberBounds = true,
|
|
44
|
+
}: {
|
|
45
|
+
width?: number;
|
|
46
|
+
height?: number;
|
|
47
|
+
disableSecurity?: boolean;
|
|
48
|
+
disableSecurityWarnings?: boolean;
|
|
49
|
+
quitOnClose?: boolean;
|
|
50
|
+
onOpen?: (win: BrowserWindow) => void;
|
|
51
|
+
onClose?: (win: BrowserWindow) => void;
|
|
52
|
+
localhostUrl?: string;
|
|
53
|
+
productionUrl?: string;
|
|
54
|
+
webPath?: string;
|
|
55
|
+
useStaticInDev?: boolean;
|
|
56
|
+
updater?: boolean;
|
|
57
|
+
rememberBounds?: boolean;
|
|
58
|
+
} = {}) {
|
|
59
|
+
let mainWindow: BrowserWindow | null = null;
|
|
60
|
+
|
|
61
|
+
console.log('[APP] Starting Electron', {
|
|
62
|
+
isDev,
|
|
63
|
+
appPath,
|
|
64
|
+
useStaticInDev,
|
|
65
|
+
localhostUrl,
|
|
66
|
+
productionUrl,
|
|
67
|
+
webPath,
|
|
68
|
+
updater,
|
|
69
|
+
rememberBounds,
|
|
70
|
+
quitOnClose,
|
|
71
|
+
disableSecurity,
|
|
72
|
+
disableSecurityWarnings,
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
if (!productionUrl && !webPath) {
|
|
76
|
+
throw new Error('productionUrl or webPath must be defined');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (productionUrl && webPath) {
|
|
80
|
+
throw new Error('productionUrl and webPath cannot be used together');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (!localhostUrl) {
|
|
84
|
+
throw new Error('localhostUrl must be defined');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const store = new Store<{ bounds: Rectangle }>({
|
|
88
|
+
schema: {
|
|
89
|
+
bounds: {
|
|
90
|
+
type: 'object',
|
|
91
|
+
properties: {
|
|
92
|
+
// Docs: https://electronjs.org/docs/api/structures/rectangle
|
|
93
|
+
height: { type: 'number' },
|
|
94
|
+
width: { type: 'number' },
|
|
95
|
+
x: { type: 'number' },
|
|
96
|
+
y: { type: 'number' },
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
if (disableSecurityWarnings) process.env['ELECTRON_DISABLE_SECURITY_WARNINGS'] = 'true';
|
|
103
|
+
process.env['ELECTRON_ENABLE_LOGGING'] = 'true';
|
|
104
|
+
|
|
105
|
+
process.on('SIGTERM', () => process.exit(0));
|
|
106
|
+
process.on('SIGINT', () => process.exit(0));
|
|
107
|
+
|
|
108
|
+
const openDevTools = () => {
|
|
109
|
+
if (!mainWindow) return;
|
|
110
|
+
mainWindow.setSize(width + 800, height);
|
|
111
|
+
mainWindow.center();
|
|
112
|
+
mainWindow.webContents.openDevTools();
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const createWindow = async () => {
|
|
116
|
+
mainWindow = new BrowserWindow({
|
|
117
|
+
width,
|
|
118
|
+
height,
|
|
119
|
+
icon: path.resolve(appPath, 'assets/icon.png'),
|
|
120
|
+
webPreferences: {
|
|
121
|
+
nodeIntegration: false, // is default value after Electron v5
|
|
122
|
+
contextIsolation: true, // protect against prototype pollution
|
|
123
|
+
webSecurity: !disableSecurity,
|
|
124
|
+
devTools: true,
|
|
125
|
+
},
|
|
126
|
+
...(rememberBounds ? store.get('bounds') : {}),
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
mainWindow.on('move', () => rememberBounds && mainWindow && store.set('bounds', mainWindow.getBounds()));
|
|
130
|
+
|
|
131
|
+
mainWindow.once('ready-to-show', () => isDev && openDevTools());
|
|
132
|
+
|
|
133
|
+
// Static interceptor
|
|
134
|
+
|
|
135
|
+
let interceptor: (() => void) | null = null;
|
|
136
|
+
|
|
137
|
+
if (webPath && (!isDev || useStaticInDev)) {
|
|
138
|
+
console.log(`[APP] Static Server Enabled, ${localhostUrl} will be intercepted to ${webPath}`);
|
|
139
|
+
|
|
140
|
+
//TODO https://localhost:3000
|
|
141
|
+
// @see https://github.com/sindresorhus/electron-serve/blob/main/index.js
|
|
142
|
+
protocol.interceptStreamProtocol('http', (request, callback) => {
|
|
143
|
+
if (request.url.startsWith(localhostUrl)) {
|
|
144
|
+
let fileUrl = url.parse(request.url, false).pathname;
|
|
145
|
+
|
|
146
|
+
if (!fileUrl) return callback({ error: FILE_NOT_FOUND });
|
|
147
|
+
|
|
148
|
+
if (fileUrl === '/') fileUrl = 'index.html';
|
|
149
|
+
|
|
150
|
+
const filePath = path.join(webPath, fileUrl);
|
|
151
|
+
|
|
152
|
+
const relativePath = path.relative(webPath, fileUrl);
|
|
153
|
+
|
|
154
|
+
const isSafe = !relativePath.startsWith('..') && !path.isAbsolute(relativePath);
|
|
155
|
+
|
|
156
|
+
if (!isSafe && !isDev) return callback({ error: FILE_NOT_FOUND });
|
|
157
|
+
|
|
158
|
+
// const finalPath = await getPath(filePath, options.file);
|
|
159
|
+
// const fileExtension = path.extname(filePath);
|
|
160
|
+
|
|
161
|
+
console.log('[APP] Static Server', fileUrl, '->', filePath);
|
|
162
|
+
|
|
163
|
+
// return {path: path.join(webPath, fileUrl)};
|
|
164
|
+
callback(fs.createReadStream(filePath));
|
|
165
|
+
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// return {url: request.url};
|
|
170
|
+
(request.url.startsWith('https') ? https : http).get(request.url, callback);
|
|
171
|
+
|
|
172
|
+
return true;
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
interceptor = () => protocol.uninterceptProtocol('http');
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
mainWindow.on('closed', () => {
|
|
179
|
+
interceptor?.();
|
|
180
|
+
onClose?.(mainWindow as never);
|
|
181
|
+
mainWindow = null;
|
|
182
|
+
interceptor = null;
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// External URLs
|
|
186
|
+
|
|
187
|
+
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
|
188
|
+
shell.openExternal(url).catch((e) => console.error(e));
|
|
189
|
+
return { action: 'deny' };
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// Menu
|
|
193
|
+
|
|
194
|
+
const menu = defaultMenu(app, shell);
|
|
195
|
+
|
|
196
|
+
if (updater) {
|
|
197
|
+
(menu.at(-1)!.submenu as any).push(
|
|
198
|
+
{ type: 'separator' },
|
|
199
|
+
{
|
|
200
|
+
label: 'Check For Updates',
|
|
201
|
+
click: checkForUpdates,
|
|
202
|
+
},
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
Menu.setApplicationMenu(Menu.buildFromTemplate(menu)); //TODO onMenu
|
|
207
|
+
|
|
208
|
+
// Should be last, after all listeners and menu
|
|
209
|
+
|
|
210
|
+
await app.whenReady();
|
|
211
|
+
|
|
212
|
+
onOpen?.(mainWindow);
|
|
213
|
+
|
|
214
|
+
if (!onOpen && localhostUrl) {
|
|
215
|
+
await mainWindow.loadURL(productionUrl && !isDev ? productionUrl : `${localhostUrl}/`);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
console.log('[APP] Main Window Open');
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
app.on('ready', createWindow);
|
|
222
|
+
|
|
223
|
+
app.on('window-all-closed', () => {
|
|
224
|
+
if (process.platform !== 'darwin' || quitOnClose) app.quit();
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
app.on('activate', () => BrowserWindow.getAllWindows().length === 0 && !mainWindow && createWindow());
|
|
228
|
+
|
|
229
|
+
return {
|
|
230
|
+
get mainWindow() {
|
|
231
|
+
return mainWindow;
|
|
232
|
+
},
|
|
233
|
+
};
|
|
234
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// https://github.com/electron-userland/electron-builder/blob/docs-deprecated/encapsulated%20manual%20update%20via%20menu.js
|
|
2
|
+
import { dialog } from 'electron';
|
|
3
|
+
import updater from 'electron-updater';
|
|
4
|
+
const { autoUpdater } = updater;
|
|
5
|
+
|
|
6
|
+
let updateButton;
|
|
7
|
+
|
|
8
|
+
autoUpdater.autoDownload = false;
|
|
9
|
+
|
|
10
|
+
autoUpdater.on('error', (error) => {
|
|
11
|
+
dialog.showErrorBox('Error: ', !error ? 'unknown' : (error.stack || error).toString());
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
autoUpdater.on('update-available', async () => {
|
|
15
|
+
const { response } = await dialog.showMessageBox({
|
|
16
|
+
type: 'info',
|
|
17
|
+
title: 'Found Updates',
|
|
18
|
+
message: 'Found updates, do you want update now?',
|
|
19
|
+
buttons: ['Yes', 'No'],
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
updateButton.enabled = true;
|
|
23
|
+
updateButton = null;
|
|
24
|
+
|
|
25
|
+
if (response === 0) await autoUpdater.downloadUpdate();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
autoUpdater.on('update-not-available', async () => {
|
|
29
|
+
try {
|
|
30
|
+
await dialog.showMessageBox({
|
|
31
|
+
title: 'No Updates',
|
|
32
|
+
message: 'Current version is up-to-date.',
|
|
33
|
+
});
|
|
34
|
+
} catch (e) {
|
|
35
|
+
dialog.showErrorBox('Error: ', e.toString());
|
|
36
|
+
}
|
|
37
|
+
updateButton.enabled = true;
|
|
38
|
+
updateButton = null;
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
autoUpdater.on('update-downloaded', async () => {
|
|
42
|
+
await dialog.showMessageBox({
|
|
43
|
+
title: 'Install Update',
|
|
44
|
+
message: 'Update downloaded, application will quit for update...',
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
autoUpdater.quitAndInstall();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
export function checkForUpdates(menuItem, focusedWindow, event) {
|
|
51
|
+
updateButton = menuItem;
|
|
52
|
+
updateButton.enabled = false;
|
|
53
|
+
autoUpdater.checkForUpdates().catch((e) => console.error('Update error', e));
|
|
54
|
+
}
|