first draft

This commit is contained in:
Quan HL
2023-09-25 13:34:29 +07:00
parent a0cd57c4dd
commit ded3808ffe
32 changed files with 22054 additions and 0 deletions

46
README.md Normal file
View File

@@ -0,0 +1,46 @@
# Getting Started with Create React App
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
## Available Scripts
In the project directory, you can run:
### `npm start`
Runs the app in the development mode.\
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
The page will reload if you make edits.\
You will also see any lint errors in the console.
### `npm test`
Launches the test runner in the interactive watch mode.\
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
### `npm run build`
Builds the app for production to the `build` folder.\
It correctly bundles React in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.\
Your app is ready to be deployed!
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
### `npm run eject`
**Note: this is a one-way operation. Once you `eject`, you cant go back!**
If you arent satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point youre on your own.
You dont have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldnt feel obligated to use this feature. However we understand that this tool wouldnt be useful if you couldnt customize it when you are ready for it.
## Learn More
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
To learn React, check out the [React documentation](https://reactjs.org/).

17
manifest.json Normal file
View File

@@ -0,0 +1,17 @@
{
"version": "1.0.0",
"manifest_version": 3,
"name": "Webrtc Chrome Extension",
"description": "Webrtc Chrome Extension",
"action": {
"default_popup": "js/index.html",
"default_title": "Webrtc Chrome Extension"
},
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["js/content.js"]
}
],
"permissions": ["activeTab", "storage"]
}

19922
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

59
package.json Normal file
View File

@@ -0,0 +1,59 @@
{
"name": "webrtc-chrome-ext",
"version": "0.1.0",
"private": true,
"dependencies": {
"@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-scripts": "5.0.1",
"typescript": "^4.9.5",
"web-vitals": "^2.1.4",
"@chakra-ui/react": "^2.8.0",
"jssip": "^3.2.17",
"react-feather": "^2.0.10",
"buffer": "^6.0.3",
"google-libphonenumber": "^3.2.33"
},
"devDependencies": {
"@types/google-libphonenumber": "^7.4.27",
"@types/jest": "^27.5.2",
"@types/node": "^16.18.53",
"@types/react": "^18.2.22",
"@types/react-dom": "^18.2.7",
"@types/chrome": "^0.0.242",
"webpack": "^5.88.2",
"webpack-cli": "^5.1.4",
"copy-webpack-plugin": "^11.0.0",
"sass": "^1.64.1",
"sass-loader": "^13.3.2",
"ts-loader": "^9.4.4",
"file-loader": "^6.2.0",
"style-loader": "^3.3.3",
"css-loader": "^6.8.1"
},
"scripts": {
"build": "webpack --config webpack.config.js",
"watch": "webpack -w --config webpack.config.js"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}

34
src/App.tsx Normal file
View File

@@ -0,0 +1,34 @@
import React from "react";
import "./styles.scss";
import { Button, Flex, Text } from "@chakra-ui/react";
import { openPhonePopup } from "./utils";
import { DEFAULT_COLOR_SCHEME } from "./common/constants";
export const App = () => {
const handleClick = () => {
openPhonePopup();
};
return (
<Flex
justifyContent="center"
flexFlow="column"
height="100%"
padding="20px"
alignItems="center"
>
<Text fontSize="xl" mb={5}>
Click 'Start' to activate the service.
</Text>
<Button
size="lg"
width="full"
onClick={handleClick}
colorScheme={DEFAULT_COLOR_SCHEME}
>
Start
</Button>
</Flex>
);
};
export default App;

2
src/common/constants.ts Normal file
View File

@@ -0,0 +1,2 @@
export const DEFAULT_COLOR_SCHEME = "pink";
export const DEFAULT_TOAST_DURATION = 5000;

77
src/common/types.ts Normal file
View File

@@ -0,0 +1,77 @@
export interface LoginCredential {
name?: string;
password?: string;
}
export interface Call {
number?: string;
state?: CallState;
action?: CallAction;
}
export enum MessageEvent {
// Request
Login,
Call,
OpenPhoneWindow,
Ping,
}
export interface EmptyData {}
export interface Message<T> {
event: MessageEvent;
data: T;
}
export interface Contact {
name: string;
number: string;
}
export interface CallHistory {
id: string;
callerName?: string;
callerId: string;
direction: string;
time: string;
duration: number;
strDuration?: string;
startTime?: number;
endTime?: number;
label?: string;
note: string;
}
export interface MessageResponse<T> {
event: MessageEvent;
code: number;
message: string;
data: T;
}
export enum CallState {
RINGING,
ANSWERED,
COMPLETED,
FAILED,
}
export enum CallAction {
OUTBOUND,
ANSWER,
CANCEL,
BYE,
DTMF,
}
export interface AppSettings {
sipDomain: string;
sipServerAddress: string;
sipUsername: string;
sipPassword: string;
sipDisplayName: string;
apiKey: string;
}
export type SipClientStatus = "online" | "offline";

79
src/content/index.tsx Normal file
View File

@@ -0,0 +1,79 @@
import React, { useEffect, useState } from "react";
import ReactDOM from "react-dom";
import {
ChakraProvider,
Text,
IconButton,
Flex,
CSSReset,
} from "@chakra-ui/react";
import { Phone } from "react-feather";
export const ContentApp = () => {
const [isOpen, setIsOpen] = useState(false);
const [position, setPosition] = useState({ left: 0, top: 0 });
const [selectedText, setSelectedText] = useState("");
// Add event listener for text selection
useEffect(() => {
function handleMouseUp() {
const selectedText = window.getSelection()?.toString();
if (selectedText) {
setSelectedText(selectedText);
const range = window.getSelection()?.getRangeAt(0);
const rect = range?.getBoundingClientRect();
setIsOpen(true);
setPosition({ left: rect?.left || 0, top: rect?.bottom || 0 });
} else {
setIsOpen(false);
}
}
document.addEventListener("mouseup", handleMouseUp);
return () => {
document.removeEventListener("mouseup", handleMouseUp);
};
}, []);
return (
<ChakraProvider>
<CSSReset />
{isOpen && (
<Flex
position="fixed"
left={position.left}
top={position.top}
bg="white"
p={4}
border="1px solid"
borderColor="gray.200"
boxShadow="md"
alignItems="center"
>
<Text>{selectedText}</Text>
<IconButton
colorScheme="blue"
ml={2}
aria-label="text"
icon={<Phone />}
/>
</Flex>
)}
</ChakraProvider>
);
};
// Create a new div as an extension root
const extensionRoot = document.createElement("div");
document.body.appendChild(extensionRoot);
// Create a shadow root
const shadowRoot = extensionRoot.attachShadow({ mode: "open" });
// Now we create a mount point in the Shadow DOM
const reactRoot = document.createElement("div");
shadowRoot.appendChild(reactRoot);
// Render React App inside our shadow root within the mount point.
ReactDOM.render(<ContentApp />, reactRoot);

1
src/declarations.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
declare module "*.png";

BIN
src/imgs/jambonz.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

16
src/index.tsx Normal file
View File

@@ -0,0 +1,16 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import { ChakraProvider } from "@chakra-ui/react";
const root = document.createElement("div");
root.className = "container";
document.body.appendChild(root);
const rootDiv = ReactDOM.createRoot(root);
rootDiv.render(
<React.StrictMode>
<ChakraProvider>
<App />
</ChakraProvider>
</React.StrictMode>
);

View File

@@ -0,0 +1,70 @@
// @ts-ignore
const ringingURL: string = "https://vibe-public.s3.eu-west-1.amazonaws.com/Tone-Telephone-UK-Ring+Tone-Loop.mp3";
const failedURL: string = "https://vibe-public.s3.eu-west-1.amazonaws.com/windows-error-sound-effect-35894.mp3";
const answeredURL: string = "https://vibe-public.s3.eu-west-1.amazonaws.com/lazer-96499.mp3";
export default class SipAudioElements {
#ringing: HTMLAudioElement;
#failed: HTMLAudioElement;
#answer: HTMLAudioElement;
#remote: HTMLAudioElement;
constructor() {
this.#ringing = new Audio();
this.#ringing.loop = true;
this.#ringing.src = ringingURL;
this.#ringing.volume = 0.8;
this.#failed = new Audio();
this.#failed.src = failedURL;
this.#failed.volume = 0.3;
this.#answer = new Audio();
this.#answer.src = answeredURL;
this.#answer.volume = 0.3;
this.#remote = new Audio();
}
playRinging(volume: number | undefined): void {
if (volume) {
this.#ringing.volume = volume;
}
this.#ringing.play();
}
pauseRinging(): void {
if (!this.#ringing.paused) {
this.#ringing.pause();
}
}
playFailed(volume: number | undefined): void {
this.pauseRinging();
if (volume) {
this.#failed.volume = volume;
}
this.#failed.play();
}
playAnswer(volume: number | undefined): void {
this.pauseRinging();
if (volume) {
this.#answer.volume = volume;
}
this.#answer.play();
}
isRemoteAudioPaused(): boolean {
return this.#remote.paused;
}
playRemote(stream: MediaStream) {
this.#remote.srcObject = stream;
this.#remote.play();
}
isPLaying(audio: HTMLAudioElement) {
return audio.currentTime > 0 && !audio.paused && !audio.ended
&& audio.readyState > audio.HAVE_CURRENT_DATA;
}
}

286
src/lib/SipSession.ts Normal file
View File

@@ -0,0 +1,286 @@
import {
EndEvent,
HoldEvent,
IceCandidateEvent,
PeerConnectionEvent,
ReferEvent,
RTCPeerConnectionDeprecated,
RTCSession,
} from "jssip/lib/RTCSession";
import { SipConstants, SipAudioElements, randomId } from "./index";
import { DTMF_TRANSPORT } from "jssip/lib/Constants";
import { IncomingResponse } from "jssip/lib/SIPMessage";
import * as events from "events";
import { C, Grammar } from "jssip";
export default class SipSession extends events.EventEmitter {
#id: string;
#rtcOptions: any;
#audio: SipAudioElements;
#rtcSession: RTCSession;
#active: boolean;
constructor(
rtcSession: RTCSession,
rtcConfig: RTCConfiguration,
audio: SipAudioElements
) {
super();
this.setMaxListeners(Infinity);
this.#id = randomId("");
this.#rtcOptions = {
mediaConstraints: { audio: true, video: false },
pcConfig: rtcConfig,
};
this.#rtcSession = rtcSession;
this.#audio = audio;
this.#active = false;
this.addListeners();
}
addListeners(): void {
if (this.#rtcSession.connection) {
this.addPeerConnectionListener(this.#rtcSession.connection);
} else {
this.#rtcSession.on(
"peerconnection",
(data: PeerConnectionEvent): void => {
let pc: RTCPeerConnectionDeprecated = data.peerconnection;
this.addPeerConnectionListener(pc);
}
);
}
this.#rtcSession.on("progress", (): void => {
this.emit(SipConstants.SESSION_RINGING, {
status: SipConstants.SESSION_RINGING,
});
if (this.#audio.isRemoteAudioPaused() && !this.replaces) {
this.#audio.playRinging(undefined);
}
});
this.#rtcSession.on("accepted", () => {
this.emit(SipConstants.SESSION_ANSWERED, {
status: SipConstants.SESSION_ANSWERED,
});
this.#audio.playAnswer(undefined);
});
this.#rtcSession.on("failed", (data: EndEvent): void => {
let { originator, cause, message } = data;
let description;
if (
message &&
originator === "remote" &&
message instanceof IncomingResponse &&
message.status_code
) {
description = `${message.status_code}`.trim();
}
if (originator === "local" && cause === C.causes.CANCELED) {
description = "Cancelled by user";
}
if (originator === "local" && cause === C.causes.REJECTED) {
description = "Rejected by user";
}
this.emit(SipConstants.SESSION_FAILED, {
cause: cause,
status: SipConstants.SESSION_FAILED,
originator: originator,
description: description,
});
if (originator === "remote") {
this.#audio.playFailed(undefined);
} else {
this.#audio.pauseRinging();
}
});
this.#rtcSession.on("ended", (data: EndEvent): void => {
const { originator, cause, message } = data;
let description;
if (message && originator === "remote" && message.hasHeader("Reason")) {
const reason = Grammar.parse(message.getHeader("Reason"), "Reason");
if (reason) {
description = `${reason.cause}`.trim();
}
}
this.emit(SipConstants.SESSION_ENDED, {
cause: cause,
status: SipConstants.SESSION_ENDED,
originator: originator,
description: description,
});
});
this.#rtcSession.on("muted", (): void => {
this.emit(SipConstants.SESSION_MUTED, {
status: "muted",
});
});
this.#rtcSession.on("unmuted", (): void => {
this.emit(SipConstants.SESSION_MUTED, {
status: "unmuted",
});
});
this.#rtcSession.on("hold", (data: HoldEvent): void => {
this.emit(SipConstants.SESSION_HOLD, {
status: "hold",
originator: data.originator,
});
});
this.#rtcSession.on("unhold", (data: HoldEvent): void => {
this.emit(SipConstants.SESSION_HOLD, {
status: "unhold",
originator: data.originator,
});
});
this.#rtcSession.on("refer", (data: ReferEvent): void => {
let { accept } = data;
accept((rtcSession: RTCSession): void => {
rtcSession.data.replaces = true;
this.emit(SipConstants.SESSION_REFER, {
session: rtcSession,
type: "refer",
});
}, this.#rtcOptions);
});
this.#rtcSession.on("replaces", (data: ReferEvent): void => {
data.accept((rtcSession: RTCSession): void => {
rtcSession.data.replaces = true;
if (!rtcSession.isEstablished()) {
rtcSession.answer(this.#rtcOptions);
this.emit(SipConstants.SESSION_REPLACES, {
session: rtcSession,
type: "replaces",
});
}
});
});
this.#rtcSession.on("icecandidate", (evt: IceCandidateEvent): void => {
let type: string[] = evt.candidate.candidate.split(" ");
let candidate: string = type[7];
if (["srflx", "relay"].indexOf(candidate) > -1) {
evt.ready();
this.emit(SipConstants.SESSION_ICE_READY, {
candidate: candidate,
status: "ready",
});
}
});
}
addPeerConnectionListener(pc: RTCPeerConnection): void {
pc.addEventListener("addstream", (event: any): void => {
if (this.#rtcSession.direction === "outgoing") {
this.#audio.pauseRinging();
}
this.#audio.playRemote(event.stream);
this.emit(SipConstants.SESSION_ADD_STREAM, {
direction: this.#rtcSession.direction,
});
});
// pc.addEventListener('track', (event: RTCPeerConnectionEventMap["track"]): void => {
// const stream: MediaStream = new MediaStream([event.track])
// if (this.#rtcSession.direction === 'outgoing') {
// this.#audio.pauseRinging();
// }
// this.#audio.playRemote(stream, "track");
// this.emit(SipConstants.SESSION_TRACK, {
// direction: this.#rtcSession.direction
// });
// });
}
get rtcSession() {
return this.#rtcSession;
}
get direction() {
return this.#rtcSession.direction;
}
get id() {
return this.#id;
}
get user() {
return this.#rtcSession.remote_identity.uri.user;
}
get active() {
return this.#active;
}
get answerTime(): Date {
return this.#rtcSession.start_time;
}
get duration() {
if (!this.answerTime) {
return 0;
}
let now: number = new Date().getUTCMilliseconds();
return Math.floor((now - this.answerTime.getUTCMilliseconds()) / 1000);
}
get replaces() {
return Boolean(this.#rtcSession.data.replaces);
}
setActive(flag: boolean): void {
let wasActive: boolean = this.#active;
this.#active = flag;
if (this.#rtcSession.isEstablished()) {
if (this.replaces) {
return;
}
if (this.#active) {
this.unhold();
} else {
this.hold();
}
}
if (this.#active && !wasActive) {
this.emit(SipConstants.SESSION_ACTIVE);
}
}
answer() {
this.#rtcSession.answer(this.#rtcOptions);
}
terminate(sipCode: number, sipReason: string): void {
this.#rtcSession.terminate({
status_code: sipCode,
reason_phrase: sipReason,
});
}
mute(): void {
this.#rtcSession.mute({ audio: true, video: true });
}
unmute(): void {
this.#rtcSession.unmute({ audio: true, video: true });
}
hold(): void {
this.#rtcSession.hold();
}
unhold(): void {
this.#rtcSession.unhold();
}
sendDtmf(tone: number | string): void {
this.#rtcSession.sendDTMF(tone, { transportType: DTMF_TRANSPORT.RFC2833 });
}
}

View File

@@ -0,0 +1,108 @@
import {
SipSession, SipModel, SipConstants
} from "./index";
export default class SipSessionManager {
#sessions: Map<string, SipModel.SipSessionState>;
constructor() {
this.#sessions = new Map();
}
activate(session: SipSession) {
this.#sessions.forEach((v, k) => {
if (k !== session.id) {
v.active = false;
session.setActive(false);
} else {
v.active = true;
session.setActive(true);
}
});
}
updateSession(field: string, session: SipSession, args: any): void {
const state: SipModel.SipSessionState = this.getSessionState(session.id);
if (state) {
switch (field) {
case SipConstants.SESSION_RINGING:
state.status = args.status;
break;
case SipConstants.SESSION_ANSWERED:
state.status = args.status;
break;
case SipConstants.SESSION_FAILED:
case SipConstants.SESSION_ENDED:
state.status = args.status;
state.endState = {
cause: args.cause,
status: args.status,
originator: args.endState,
description: args.description
}
this.#sessions.delete(session.id);
break;
case SipConstants.SESSION_MUTED:
state.muteStatus = args.status;
break;
case SipConstants.SESSION_HOLD:
state.holdState = {
originator: args.originator,
status: args.status
}
break;
case SipConstants.SESSION_ICE_READY:
state.iceReady = true;
break;
case SipConstants.SESSION_ACTIVE:
state.active = true;
break;
}
}
}
getSessionState(id: string): SipModel.SipSessionState {
const state = this.#sessions.get(id);
if (!state) {
throw new Error("Session not found");
}
return state;
}
getSession(id: string): SipSession {
return this.getSessionState(id).sipSession;
}
newSession(session: SipSession): void {
this.#sessions.set(session.id,
{
id: session.id,
sipSession: session,
startDateTime: new Date(),
active: true,
status: 'init',
});
}
get activeSession(): SipSession {
if (this.#sessions.size === 0) {
throw new Error("No sessions");
}
const state = [...this.#sessions.values()].filter((s) => s.active);
if (state.length) {
return state[0].sipSession;
} else {
throw new Error("No Active sessions");
}
}
get count() {
return this.#sessions.size;
}
}

198
src/lib/SipUA.ts Normal file
View File

@@ -0,0 +1,198 @@
import * as events from "events";
import { UA, WebSocketInterface, debug } from "jssip";
import { DisconnectEvent } from "jssip/lib/WebSocketInterface";
import { RTCSession } from "jssip/lib/RTCSession";
import {
ConnectedEvent,
IncomingRTCSessionEvent,
RegisteredEvent,
UAConnectingEvent,
UnRegisteredEvent,
} from "jssip/lib/UA";
import {
SessionManager,
SipAudioElements,
SipSession,
SipConstants,
SipModel,
normalizeNumber,
} from "./index";
export default class SipUA extends events.EventEmitter {
#ua: UA;
#rtcConfig: RTCConfiguration;
#sessionManager: SessionManager;
constructor(client: SipModel.ClientAuth, settings: SipModel.ClientOptions) {
super();
debug.enable("JsSIP:*");
this.#sessionManager = new SessionManager();
this.#rtcConfig = settings.pcConfig;
this.#ua = new UA({
uri: `sip:${client.username}`,
password: client.password,
display_name: client.name,
sockets: [new WebSocketInterface(settings.wsUri)],
register: settings.register,
});
this.#ua.on("connecting", (data: UAConnectingEvent) =>
this.emit(SipConstants.UA_CONNECTING, { ...data, client })
);
this.#ua.on("connected", (data: ConnectedEvent) =>
this.emit(SipConstants.UA_CONNECTED, { ...data, client })
);
this.#ua.on("disconnected", (data: DisconnectEvent) =>
this.emit(SipConstants.UA_DISCONNECTED, {
...data,
client,
})
);
this.#ua.on("registered", (data: RegisteredEvent) =>
this.emit(SipConstants.UA_REGISTERED, { ...data, client })
);
this.#ua.on("unregistered", (data: UnRegisteredEvent) =>
this.emit(SipConstants.UA_UNREGISTERED, {
...data,
client,
})
);
this.#ua.on("registrationFailed", (data: UnRegisteredEvent) =>
this.emit(SipConstants.UA_UNREGISTERED, {
...data,
client,
})
);
this.#ua.on("newRTCSession", (data: IncomingRTCSessionEvent) => {
const rtcSession: RTCSession = data.session;
const session: SipSession = new SipSession(
rtcSession,
this.#rtcConfig,
new SipAudioElements()
);
this.#sessionManager.newSession(session);
session.on(SipConstants.SESSION_RINGING, (args) =>
this.updateSession(SipConstants.SESSION_RINGING, session, args, client)
);
session.on(SipConstants.SESSION_ANSWERED, (args) =>
this.updateSession(SipConstants.SESSION_ANSWERED, session, args, client)
);
session.on(SipConstants.SESSION_FAILED, (args) =>
this.updateSession(SipConstants.SESSION_FAILED, session, args, client)
);
session.on(SipConstants.SESSION_ENDED, (args) =>
this.updateSession(SipConstants.SESSION_ENDED, session, args, client)
);
session.on(SipConstants.SESSION_MUTED, (args) =>
this.updateSession(SipConstants.SESSION_MUTED, session, args, client)
);
session.on(SipConstants.SESSION_HOLD, (args) =>
this.updateSession(SipConstants.SESSION_HOLD, session, args, client)
);
session.on(SipConstants.SESSION_UNHOLD, (args) =>
this.updateSession(SipConstants.SESSION_UNHOLD, session, args, client)
);
session.on(SipConstants.SESSION_ICE_READY, (args) =>
this.updateSession(
SipConstants.SESSION_ICE_READY,
session,
args,
client
)
);
session.on(SipConstants.SESSION_ACTIVE, (args) => {
this.updateSession(SipConstants.SESSION_ACTIVE, session, args, client);
});
session.setActive(true);
});
}
updateSession(
field: string,
session: SipSession,
args: any,
client: SipModel.ClientAuth
) {
this.emit(field, { ...args, client, session });
this.#sessionManager.updateSession(field, session, args);
}
start(): void {
this.#ua.start();
this.emit(SipConstants.UA_START);
}
stop(): void {
this.#ua.stop();
this.emit(SipConstants.UA_STOP);
}
call(number: string): void {
let normalizedNumber: string = normalizeNumber(number);
this.#ua.call(normalizedNumber, {
extraHeaders: [`X-Original-Number:${number}`],
mediaConstraints: { audio: true, video: false },
pcConfig: this.#rtcConfig,
});
}
mute(id: string | undefined): void {
if (id) {
this.#sessionManager.getSession(id).mute();
} else {
this.#sessionManager.activeSession.mute();
}
}
unmute(id: string | undefined): void {
if (id) {
this.#sessionManager.getSession(id).unmute();
} else {
this.#sessionManager.activeSession.unmute();
}
}
hold(id: string | undefined): void {
if (id) {
this.#sessionManager.getSession(id).hold();
} else {
this.#sessionManager.activeSession.hold();
}
}
unhold(id: string | undefined): void {
if (id) {
this.#sessionManager.getSession(id).unhold();
} else {
this.#sessionManager.activeSession.unhold();
}
}
dtmf(tone: number | string, id: string | undefined): void {
if (id) {
this.#sessionManager.getSession(id).sendDtmf(tone);
} else {
this.#sessionManager.activeSession.sendDtmf(tone);
}
}
terminate(sipCode: number, reason: string, id: string | undefined): void {
if (id) {
this.#sessionManager.getSession(id).terminate(sipCode, reason);
} else {
this.#sessionManager.activeSession.terminate(sipCode, reason);
}
}
answer(id: string | undefined): void {
if (id) {
this.#sessionManager.getSession(id).answer();
} else {
this.#sessionManager.activeSession.answer();
}
}
activate(id: string) {
const session: SipSession = this.#sessionManager.getSession(id);
session.setActive(true);
}
}

18
src/lib/index.ts Normal file
View File

@@ -0,0 +1,18 @@
import SipSession from "./SipSession";
import SessionManager from "./SipSessionManager";
import SipAudioElements from "./SipAudioElements";
import SipUA from "./SipUA";
import {normalizeNumber, randomId} from "./sip-utils";
import * as SipModel from "./sip-models";
import * as SipConstants from "./sip-constants";
export {
SipUA,
SipSession,
SessionManager,
SipAudioElements,
randomId,
normalizeNumber,
SipConstants,
SipModel
}

20
src/lib/sip-constants.ts Normal file
View File

@@ -0,0 +1,20 @@
export const SESSION_RINGING: string = "ringing";
export const SESSION_ANSWERED: string = "answered";
export const SESSION_FAILED: string = "failed";
export const SESSION_ENDED: string = "ended";
export const SESSION_MUTED: string = "muted";
export const SESSION_HOLD: string = "hold";
export const SESSION_UNHOLD: string = "unhold";
export const SESSION_REFER: string = "refer";
export const SESSION_REPLACES: string = "replaces";
export const SESSION_ICE_READY: string = "ice-ready";
export const SESSION_ADD_STREAM: string = "add-stream";
export const SESSION_TRACK: string = "track";
export const SESSION_ACTIVE: string = "active";
export const UA_CONNECTING: string = "connecting";
export const UA_CONNECTED: string = "connected";
export const UA_DISCONNECTED: string = "disconnected";
export const UA_REGISTERED: string = "registered";
export const UA_UNREGISTERED: string = "unregistered";
export const UA_START: string = "start";
export const UA_STOP: string = "stop";

45
src/lib/sip-models.ts Normal file
View File

@@ -0,0 +1,45 @@
import SipSession from "./SipSession";
export interface SipSessionState {
id: string;
sipSession: SipSession;
startDateTime: Date;
active: boolean;
status: string;
muteStatus?: string;
iceReady?: boolean;
endState?: EndState;
holdState?: HoldState;
}
export interface EndState {
cause: string;
status: string;
originator: string;
description: string;
}
export interface HoldState {
status: string;
originator: string;
}
export interface ClientAuth {
username: string;
password: string;
name: string;
}
export interface ClientOptions {
pcConfig: PeerConnectionConfig;
wsUri: string;
register: boolean;
}
export interface PeerConnectionConfig {
iceServers: IceServer[];
}
export interface IceServer {
urls: string[];
}

23
src/lib/sip-utils.ts Normal file
View File

@@ -0,0 +1,23 @@
function normalizeNumber(number: string): string {
if (/^(sips?|tel):/i.test(number)) {
return number;
} else if (/@/i.test(number)) {
return number;
} else {
return number.replace(/[()\-. ]*/g, '');
}
}
function randomId(prefix: string): string {
const id: string = [...Array(16)].map(() => Math.floor(Math.random() * 16).toString(16)).join('');
if (prefix) {
return `${prefix}-${id}`;
} else {
return id;
}
}
export {
normalizeNumber,
randomId
}

22
src/storage/index.ts Normal file
View File

@@ -0,0 +1,22 @@
import { AppSettings } from "src/common/types";
import { Buffer } from "buffer";
// Settings
const SETTINGS_KEY = "SettingsKey";
export const saveSettings = (settings: AppSettings) => {
const encoded = Buffer.from(JSON.stringify(settings), "utf-8").toString(
"base64"
);
localStorage.setItem(SETTINGS_KEY, encoded);
};
export const getSettings = (): AppSettings => {
const str = localStorage.getItem(SETTINGS_KEY);
if (str) {
const planText = Buffer.from(str, "base64").toString("utf-8");
return JSON.parse(planText) as AppSettings;
}
return {} as AppSettings;
};

7
src/styles.scss Normal file
View File

@@ -0,0 +1,7 @@
.container {
width: 280px;
height: 480px;
font-family: Arial, sans-serif;
margin: 0;
padding: 0;
}

56
src/utils/index.ts Normal file
View File

@@ -0,0 +1,56 @@
import { SipConstants } from "src/lib";
import { deleteWindowIdKey, getWindowIdKey, saveWindowIdKey } from "./storage";
import { PhoneNumberFormat, PhoneNumberUtil } from "google-libphonenumber";
export const formatPhoneNumber = (number: string) => {
try {
const phoneUtil = PhoneNumberUtil.getInstance();
const phoneNumber = phoneUtil.parse(number, "US");
return phoneUtil.format(phoneNumber, PhoneNumberFormat.NATIONAL);
} catch (error) {}
return number;
};
export const openPhonePopup = () => {
const runningPhoneWindowId = getWindowIdKey();
if (runningPhoneWindowId) {
chrome.windows.update(runningPhoneWindowId, { focused: true }, () => {
if (chrome.runtime.lastError) {
deleteWindowIdKey();
initiateNewPhonePopup();
}
});
} else {
initiateNewPhonePopup();
}
};
const initiateNewPhonePopup = () => {
const cfg: chrome.windows.CreateData = {
url: chrome.runtime.getURL("window/index.html"),
width: 300,
height: 630,
focused: true,
type: "panel",
state: "normal",
};
chrome.windows.create(cfg, (w) => {
if (w && w.id) saveWindowIdKey(w.id);
});
};
export const isSipClientRinging = (callStatus: string) => {
return callStatus === SipConstants.SESSION_RINGING;
};
export const isSipClientAnswered = (callStatus: string) => {
return callStatus === SipConstants.SESSION_ANSWERED;
};
export const isSipClientIdle = (callStatus: string) => {
return (
callStatus === SipConstants.SESSION_ENDED ||
callStatus === SipConstants.SESSION_FAILED
);
};

18
src/utils/storage.tsx Normal file
View File

@@ -0,0 +1,18 @@
// window ID
const windowIdKey = "windowIdKey";
export const saveWindowIdKey = (id: number) => {
localStorage.setItem(windowIdKey, id.toString());
};
export const getWindowIdKey = (): number => {
const str = localStorage.getItem(windowIdKey);
if (str) {
return Number(str);
}
return -1;
};
export const deleteWindowIdKey = () => {
localStorage.removeItem(windowIdKey);
};

86
src/window/app.tsx Normal file
View File

@@ -0,0 +1,86 @@
import { Box, Tab, TabList, TabPanel, TabPanels, Tabs } from "@chakra-ui/react";
import Phone from "./phone";
import Settings from "./settings";
import { DEFAULT_COLOR_SCHEME } from "src/common/constants";
import { useEffect, useState } from "react";
import { getSettings } from "src/storage";
export const WindowApp = () => {
const [sipDomain, setSipDomain] = useState("");
const [sipUsername, setSipUsername] = useState("");
const [sipServerAddress, setSipServerAddress] = useState("");
const [sipPassword, setSipPassword] = useState("");
const [sipDisplayName, setSipDisplayName] = useState("");
const tabsSettings = [
{
title: "Phone",
content: (
<Phone
sipUsername={sipUsername}
sipPassword={sipPassword}
sipDomain={sipDomain}
sipDisplayName={sipDisplayName}
sipServerAddress={sipServerAddress}
/>
),
},
{
title: "Settings",
content: <Settings />,
},
];
useEffect(() => {
loadSettings();
}, []);
const onTabsChange = () => {
loadSettings();
};
const loadSettings = () => {
const settings = getSettings();
if (settings.sipDomain && settings.sipDomain !== sipDomain) {
setSipDomain(settings.sipDomain);
}
if (
settings.sipServerAddress &&
settings.sipServerAddress !== sipServerAddress
) {
setSipServerAddress(settings.sipServerAddress);
}
if (settings.sipUsername && settings.sipUsername !== sipUsername) {
setSipUsername(settings.sipUsername);
}
if (settings.sipPassword && settings.sipPassword !== sipPassword) {
setSipPassword(settings.sipPassword);
}
if (settings.sipDisplayName && settings.sipDisplayName !== sipDisplayName) {
setSipDisplayName(settings.sipDisplayName);
}
};
return (
<Box p={4}>
<Tabs
isFitted
variant="enclosed"
colorScheme={DEFAULT_COLOR_SCHEME}
onChange={onTabsChange}
>
<TabList mb="1em">
{tabsSettings.map((s) => (
<Tab>{s.title}</Tab>
))}
</TabList>
<TabPanels>
{tabsSettings.map((s) => (
<TabPanel>{s.content}</TabPanel>
))}
</TabPanels>
</Tabs>
</Box>
);
};
export default WindowApp;

40
src/window/dial-pad.tsx Normal file
View File

@@ -0,0 +1,40 @@
import { Button, HStack, VStack } from "@chakra-ui/react";
type DialPadProbs = {
handleDigitPress: (digit: string) => void;
};
export const DialPad = ({ handleDigitPress }: DialPadProbs) => {
const buttons = [
["1", "2", "3"],
["4", "5", "6"],
["7", "8", "9"],
["*", "0", "#"],
];
return (
<VStack w="full" bg="black" spacing={0.5}>
{buttons.map((row, rowIndex) => (
<HStack key={rowIndex} justifyContent="space-between" spacing={0.5}>
{row.map((num) => (
<Button
key={num}
onClick={() => handleDigitPress(num)}
size="lg"
p={0}
width="70px"
height="70px"
variant="unstyled"
bg="white"
borderRadius={0}
>
{num}
</Button>
))}
</HStack>
))}
</VStack>
);
};
export default DialPad;

View File

@@ -0,0 +1,81 @@
import {
Button,
VStack,
Text,
Tooltip,
IconButton,
Collapse,
} from "@chakra-ui/react";
import { useState } from "react";
import { Smartphone } from "react-feather";
import { DEFAULT_COLOR_SCHEME } from "src/common/constants";
import {
formatPhoneNumber,
isSipClientAnswered,
isSipClientRinging,
} from "src/utils";
import DialPad from "./dial-pad";
type IncommingCallProbs = {
number: string;
callStatus: string;
answer: () => void;
hangup: () => void;
decline: () => void;
handleDialPadClick: (s: string) => void;
};
export const IncommingCall = ({
number,
callStatus,
answer,
hangup,
decline,
handleDialPadClick,
}: IncommingCallProbs) => {
const [showDialPad, setShowDialPad] = useState(false);
return (
<VStack alignItems="center" spacing={4} mt={5}>
{!showDialPad && (
<>
<Text fontSize="2xl" fontWeight="bold">
Incoming call from
</Text>
<Text fontSize="2xl" fontWeight="bold">
{formatPhoneNumber(number)}
</Text>
</>
)}
{isSipClientAnswered(callStatus) && (
<Tooltip label="Dial pad">
<IconButton
colorScheme={DEFAULT_COLOR_SCHEME}
aria-label="Toggle Dialpad"
icon={<Smartphone />}
onClick={() => setShowDialPad((prev) => !prev)}
/>
</Tooltip>
)}
<Collapse in={showDialPad}>
<DialPad handleDigitPress={handleDialPadClick} />
</Collapse>
<Button
w="full"
colorScheme={DEFAULT_COLOR_SCHEME}
onClick={isSipClientRinging(callStatus) ? answer : hangup}
>
{isSipClientRinging(callStatus) ? "Answer" : "Hang up"}
</Button>
{isSipClientRinging(callStatus) && (
<Button w="full" colorScheme={DEFAULT_COLOR_SCHEME} onClick={decline}>
Decline
</Button>
)}
</VStack>
);
};
export default IncommingCall;

16
src/window/index.tsx Normal file
View File

@@ -0,0 +1,16 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { ChakraProvider } from "@chakra-ui/react";
import WindownApp from "./app";
const root = document.createElement("div");
root.className = "container";
document.body.appendChild(root);
const rootDiv = ReactDOM.createRoot(root);
rootDiv.render(
<React.StrictMode>
<ChakraProvider>
<WindownApp />
</ChakraProvider>
</React.StrictMode>
);

View File

@@ -0,0 +1,92 @@
import {
Button,
VStack,
Text,
IconButton,
Collapse,
Tooltip,
} from "@chakra-ui/react";
import { useEffect, useRef, useState } from "react";
import { DEFAULT_COLOR_SCHEME } from "src/common/constants";
import { formatPhoneNumber, isSipClientAnswered } from "src/utils";
import DialPad from "./dial-pad";
import { Smartphone } from "react-feather";
type IncommingCallProbs = {
number: string;
callStatus: string;
callHold: boolean;
hangup: () => void;
callOnHold: () => void;
handleDialPadClick: (s: string) => void;
};
export const OutgoingCall = ({
number,
callStatus,
callHold,
hangup,
callOnHold,
handleDialPadClick,
}: IncommingCallProbs) => {
const [seconds, setSeconds] = useState(0);
const timerRef = useRef<NodeJS.Timer | null>(null);
const [showDialPad, setShowDialPad] = useState(false);
useEffect(() => {
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
timerRef.current = setInterval(() => {
setSeconds((seconds) => seconds + 1);
}, 1000);
}, []);
return (
<VStack alignItems="center" spacing={4} mt={5}>
{!showDialPad && (
<>
<Text fontSize="2xl" fontWeight="bold">
Talking to
</Text>
<Text fontSize="2xl" fontWeight="bold">
{formatPhoneNumber(number)}
</Text>
<Text fontSize="xl" fontWeight="bold">
{new Date(seconds * 1000).toISOString().substr(11, 8)}
</Text>
</>
)}
{isSipClientAnswered(callStatus) && (
<Tooltip label="Dial pad">
<IconButton
colorScheme={DEFAULT_COLOR_SCHEME}
aria-label="Toggle Dialpad"
icon={<Smartphone />}
onClick={() => setShowDialPad((prev) => !prev)}
/>
</Tooltip>
)}
<Collapse in={showDialPad}>
<DialPad handleDigitPress={handleDialPadClick} />
</Collapse>
<VStack spacing={5}>
<Button w="full" colorScheme={DEFAULT_COLOR_SCHEME} onClick={hangup}>
Hang up
</Button>
<Button
w="full"
colorScheme={DEFAULT_COLOR_SCHEME}
onClick={callOnHold}
>
{callHold ? "Unhold " : "Place call on hold"}
</Button>
</VStack>
</VStack>
);
};
export default OutgoingCall;

272
src/window/phone.tsx Normal file
View File

@@ -0,0 +1,272 @@
import {
Button,
Center,
Flex,
HStack,
Heading,
IconButton,
Input,
InputGroup,
InputRightElement,
Spacer,
Text,
VStack,
} from "@chakra-ui/react";
import { useEffect, useRef, useState } from "react";
import { Delete } from "react-feather";
import { DEFAULT_COLOR_SCHEME } from "src/common/constants";
import { SipClientStatus } from "src/common/types";
import { SipConstants, SipUA } from "src/lib";
import IncommingCall from "./incomming-call";
import OutgoingCall from "./outgoing-call";
import DialPad from "./dial-pad";
import {
isSipClientAnswered,
isSipClientIdle,
isSipClientRinging,
} from "src/utils";
type PhoneProbs = {
sipDomain: string;
sipServerAddress: string;
sipUsername: string;
sipPassword: string;
sipDisplayName: string;
};
export const Phone = ({
sipDomain,
sipServerAddress,
sipUsername,
sipPassword,
sipDisplayName,
}: PhoneProbs) => {
const [inputNumber, setInputNumber] = useState("");
const [status, setStatus] = useState<SipClientStatus>("offline");
const [goOffline, setGoOffline] = useState(false);
const [backtoOnline, setBackToOnline] = useState(false);
const [callStatus, setCallStatus] = useState(SipConstants.SESSION_ENDED);
const [sessionDirection, setSessionDirection] = useState("incoming");
const [callHold, setCallHold] = useState(false);
const sipUA = useRef<SipUA | null>(null);
useEffect(() => {
if (sipDomain && sipUsername && sipPassword) {
createSipClient();
}
}, [sipDomain, sipUsername, sipPassword, sipServerAddress, sipDisplayName]);
const createSipClient = (forceOfflineMode = false) => {
if (goOffline && !forceOfflineMode) {
return;
}
clientGoOffline();
const client = {
username: `${sipUsername}@${sipDomain}`,
password: sipPassword,
name: sipDisplayName ?? sipUsername,
};
const settings = {
pcConfig: {
iceServers: [{ urls: ["stun:stun.l.google.com:19302"] }],
},
wsUri: sipServerAddress,
register: true,
};
const sipClient = new SipUA(client, settings);
// UA Status
sipClient.on(SipConstants.UA_REGISTERED, (args) => {
setStatus("online");
setBackToOnline(false);
});
sipClient.on(SipConstants.UA_UNREGISTERED, (args) => {
setStatus("offline");
clientGoOffline();
setBackToOnline(false);
});
// Call Status
sipClient.on(SipConstants.SESSION_RINGING, (args) => {
setCallStatus(SipConstants.SESSION_RINGING);
setSessionDirection(args.session.direction);
setInputNumber(args.session.user);
});
sipClient.on(SipConstants.SESSION_ANSWERED, (args) => {
setCallStatus(SipConstants.SESSION_ANSWERED);
});
sipClient.on(SipConstants.SESSION_ENDED, (args) => {
setCallStatus(SipConstants.SESSION_ENDED);
setSessionDirection("");
});
sipClient.on(SipConstants.SESSION_FAILED, (args) => {
setCallStatus(SipConstants.SESSION_FAILED);
setSessionDirection("");
});
sipClient.on(SipConstants.SESSION_HOLD, (args) => {
setCallHold(true);
});
sipClient.on(SipConstants.SESSION_UNHOLD, (args) => {
setCallHold(false);
});
sipClient.start();
sipUA.current = sipClient;
};
const handleDialPadClick = (value: string) => {
if (isSipClientIdle(callStatus)) {
setInputNumber((prev) => prev + value);
} else if (isSipClientAnswered(callStatus)) {
sipUA.current?.dtmf(value, undefined);
}
};
const handleCallButtion = () => {
if (sipUA.current) {
sipUA.current.call(inputNumber);
}
};
const clientGoOffline = () => {
if (sipUA.current) {
sipUA.current.stop();
sipUA.current = null;
}
};
const handleGoOffline = () => {
const newVal = !goOffline;
setGoOffline(newVal);
if (newVal) {
clientGoOffline();
} else {
createSipClient(true);
setBackToOnline(true);
}
};
const handleHangup = () => {
if (isSipClientAnswered(callStatus) || isSipClientRinging(callStatus)) {
sipUA.current?.terminate(480, "Call Finished", undefined);
}
};
const handleCallOnHold = () => {
if (isSipClientAnswered(callStatus)) {
if (callHold) {
sipUA.current?.unhold(undefined);
} else {
sipUA.current?.hold(undefined);
}
}
};
const handleAnswer = () => {
if (isSipClientRinging(callStatus)) {
sipUA.current?.answer(undefined);
}
};
const handleDecline = () => {
if (isSipClientRinging(callStatus)) {
sipUA.current?.terminate(486, "Busy here", undefined);
}
};
return (
<Center flexDirection="column">
{status === "online" || goOffline || backtoOnline ? (
<VStack mb={2}>
<Text fontWeight="bold" maxW="full">
{`${sipUsername}@${sipDomain}`}
</Text>
<Flex alignItems="center" justifyContent="space-between" w="full">
<Text>
<strong>Status:</strong> {status}
</Text>
<Spacer />
<Button
size="sm"
onClick={handleGoOffline}
colorScheme={DEFAULT_COLOR_SCHEME}
ml={5}
>
{goOffline ? "Go online" : "Go offline"}
</Button>
</Flex>
</VStack>
) : (
<Heading size="md" mb={2}>
Go to Settings to configure your account
</Heading>
)}
{!isSipClientIdle(callStatus) ? (
sessionDirection === "outgoing" ? (
<OutgoingCall
callHold={callHold}
callStatus={callStatus}
number={inputNumber}
hangup={handleHangup}
callOnHold={handleCallOnHold}
handleDialPadClick={handleDialPadClick}
/>
) : (
<IncommingCall
number={inputNumber}
callStatus={callStatus}
answer={handleAnswer}
hangup={handleHangup}
decline={handleDecline}
handleDialPadClick={handleDialPadClick}
/>
)
) : (
<VStack w="200px" spacing={4}>
<Center flexDirection="column">
<InputGroup>
<Input
value={inputNumber}
shadow="md"
fontWeight="bold"
onChange={(e) => setInputNumber(e.target.value)}
/>
<InputRightElement
children={
<IconButton
aria-label="Delete text"
icon={<Delete />}
variant="ghost"
colorScheme={DEFAULT_COLOR_SCHEME}
_hover={{ bg: "none" }}
_active={{ bg: "none" }}
onClick={() => setInputNumber((prev) => prev.slice(0, -1))}
/>
}
/>
</InputGroup>
</Center>
<DialPad handleDigitPress={handleDialPadClick} />
<HStack alignItems="center" w="80%">
<Button
w="full"
onClick={handleCallButtion}
isDisabled={status === "offline"}
colorScheme={DEFAULT_COLOR_SCHEME}
>
Call
</Button>
</HStack>
</VStack>
)}
</Center>
);
};
export default Phone;

141
src/window/settings.tsx Normal file
View File

@@ -0,0 +1,141 @@
import {
Button,
FormControl,
FormLabel,
Input,
VStack,
useToast,
} from "@chakra-ui/react";
import React, { useEffect, useState } from "react";
import { DEFAULT_TOAST_DURATION } from "src/common/constants";
import { AppSettings } from "src/common/types";
import { getSettings, saveSettings } from "src/storage";
export const Settings = () => {
const [sipDomain, setSipDomain] = useState("");
const [sipServerAddress, setSipServerAddress] = useState("");
const [sipUsername, setSipUsername] = useState("");
const [sipPassword, setSipPassword] = useState("");
const [sipDisplayName, setSipDisplayName] = useState("");
const [apiKey, setApiKey] = useState("");
const toast = useToast();
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const settings: AppSettings = {
sipDomain,
sipServerAddress: sipServerAddress,
sipUsername,
sipPassword,
sipDisplayName,
apiKey,
};
saveSettings(settings);
toast({
title: "Settings saved successfully",
status: "success",
duration: DEFAULT_TOAST_DURATION,
isClosable: true,
});
};
useEffect(() => {
const settings = getSettings();
if (settings.sipDomain) {
setSipDomain(settings.sipDomain);
}
if (settings.sipServerAddress) {
setSipServerAddress(settings.sipServerAddress);
}
if (settings.sipUsername) {
setSipUsername(settings.sipUsername);
}
if (settings.sipPassword) {
setSipPassword(settings.sipPassword);
}
if (settings.sipDisplayName) {
setSipDisplayName(settings.sipDisplayName);
}
if (settings.apiKey) {
setApiKey(settings.apiKey);
}
}, []);
return (
<form onSubmit={handleSubmit}>
<VStack spacing={4}>
<FormControl id="jambonz_sip_domain">
<FormLabel>Jambonz SIP Domain</FormLabel>
<Input
type="text"
placeholder="Domain"
isRequired
value={sipDomain}
onChange={(e) => setSipDomain(e.target.value)}
/>
</FormControl>
<FormControl id="jambonz_server_address">
<FormLabel>Jambonz Server Address</FormLabel>
<Input
type="text"
placeholder="Server address"
isRequired
value={sipServerAddress}
onChange={(e) => setSipServerAddress(e.target.value)}
/>
</FormControl>
<FormControl id="username">
<FormLabel>SIP Username</FormLabel>
<Input
type="text"
placeholder="Username"
isRequired
value={sipUsername}
onChange={(e) => setSipUsername(e.target.value)}
/>
</FormControl>
<FormControl id="password">
<FormLabel>SIP Password</FormLabel>
<Input
type="password"
placeholder="Enter your password"
isRequired
value={sipPassword}
onChange={(e) => setSipPassword(e.target.value)}
/>
</FormControl>
<FormControl id="sip_display_name">
<FormLabel>SIP Display Name</FormLabel>
<Input
type="text"
placeholder="Display name"
isRequired
value={sipDisplayName}
onChange={(e) => setSipDisplayName(e.target.value)}
/>
</FormControl>
<FormControl id="api_key">
<FormLabel>API Key (optional)</FormLabel>
<Input
type="password"
placeholder="API Key"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
/>
</FormControl>
<Button colorScheme="pink" type="submit" w="80%">
Save
</Button>
</VStack>
</form>
);
};
export default Settings;

26
tsconfig.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"types": ["@types/react", "@types/react-dom", "@types/chrome", "node"],
"target": "ESNext",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": false,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"removeComments": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"baseUrl": ".",
"paths": {
"src": ["./src"]
}
},
"include": ["src", "src/**/*", "src/img"]
}

176
webpack.config.js Normal file
View File

@@ -0,0 +1,176 @@
const path = require("path");
const HTMLPlugin = require("html-webpack-plugin");
const CopyPlugin = require("copy-webpack-plugin");
module.exports = [
{
entry: "./src/content/index.tsx",
target: "web",
output: {
path: path.join(__dirname, "dist"),
filename: "js/content.js",
},
module: {
rules: [
{
test: /\.tsx?$/,
use: [
{
loader: "ts-loader",
options: {
compilerOptions: { noEmit: false },
},
},
],
exclude: /node_modules/,
},
],
},
resolve: {
extensions: [".ts", ".js"],
},
},
{
entry: {
index: "./src/index.tsx",
},
mode: "production",
module: {
rules: [
{
test: /\.tsx?$/,
use: [
{
loader: "ts-loader",
options: {
compilerOptions: { noEmit: false },
},
},
],
exclude: /node_modules/,
},
{
exclude: /node_modules/,
test: /\.css$/i,
use: ["style-loader", "css-loader"],
},
{
test: /\.s[ac]ss$/i,
use: [
// Creates `style` nodes from JS strings
"style-loader",
// Translates CSS into CommonJS
"css-loader",
// Compiles Sass to CSS
"sass-loader",
],
},
{
test: /\.(png|jpe?g|gif|svg)$/i,
use: [
{
loader: "file-loader",
},
],
},
],
},
plugins: [
new CopyPlugin({
patterns: [{ from: "manifest.json", to: "../manifest.json" }],
}),
...getHtmlPlugins(["index"]),
],
resolve: {
alias: {
src: path.resolve(__dirname, "src/"),
},
extensions: [".tsx", ".ts", ".js"],
},
output: {
path: path.join(__dirname, "dist/js"),
filename: "[name].js",
},
performance: {
maxEntrypointSize: 512000,
maxAssetSize: 512000,
},
},
{
entry: {
index: "./src/window/index.tsx",
},
mode: "production",
module: {
rules: [
{
test: /\.tsx?$/,
use: [
{
loader: "ts-loader",
options: {
compilerOptions: { noEmit: false },
},
},
],
exclude: /node_modules/,
},
{
exclude: /node_modules/,
test: /\.css$/i,
use: ["style-loader", "css-loader"],
},
{
test: /\.s[ac]ss$/i,
use: [
// Creates `style` nodes from JS strings
"style-loader",
// Translates CSS into CommonJS
"css-loader",
// Compiles Sass to CSS
"sass-loader",
],
},
{
test: /\.(png|jpe?g|gif|svg)$/i,
use: [
{
loader: "file-loader",
},
],
},
],
},
plugins: [
new CopyPlugin({
patterns: [{ from: "manifest.json", to: "../manifest.json" }],
}),
...getHtmlPlugins(["index"]),
],
resolve: {
alias: {
src: path.resolve(__dirname, "src/"),
},
extensions: [".tsx", ".ts", ".js"],
},
output: {
path: path.join(__dirname, "dist/window"),
filename: "[name].js",
},
performance: {
maxEntrypointSize: 512000,
maxAssetSize: 512000,
},
},
];
function getHtmlPlugins(chunks) {
return chunks.map(
(chunk) =>
new HTMLPlugin({
title: "Webrtc Chrome Extension",
filename: `${chunk}.html`,
chunks: [chunk],
})
);
}